From d894e25db786fe712c1f65c5a5b6f432f2e2bfa3 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Sat, 23 May 2026 00:10:41 -0300 Subject: [PATCH 1/2] runtime: forward pty resize through ExecContainer Add InitialTtySize and ResizeCh to runtime.ExecOptions (mirrored on devcontainer.ExecOptions) so callers driving an interactive exec can: - set the initial pty geometry at create time, matching the host terminal so curses apps don't render at 80x24 until first redraw - stream subsequent size updates for the lifetime of the exec, so resizing the terminal window reflows the in-container application Docker backend: pass ConsoleSize to ExecCreate and run a small goroutine that drains the resize channel into ExecResize daemon calls. The goroutine coalesces bursts (keeps only the latest size when several updates arrive between API round-trips) so a fast drag doesn't back up. Apple-container backend: out of scope for this commit; falls back to the no-resize behavior it already exhibits. Co-Authored-By: Claude Opus 4.7 (1M context) --- exec.go | 29 ++++++++++++++++------ runtime/docker/exec.go | 56 ++++++++++++++++++++++++++++++++++++++++++ runtime/runtime.go | 21 ++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/exec.go b/exec.go index 9633a88..4feb694 100644 --- a/exec.go +++ b/exec.go @@ -23,6 +23,17 @@ type ExecOptions struct { Stdout io.Writer Stderr io.Writer + // InitialTtySize, when Tty is true and both dimensions are + // non-zero, sets the initial pty geometry. Ignored when Tty is + // false. + InitialTtySize runtime.TtySize + + // ResizeCh, when non-nil and Tty is true, delivers pty geometry + // updates for the lifetime of the exec. The caller owns the + // channel and MUST NOT close it before Exec returns. Ignored when + // Tty is false. + ResizeCh <-chan runtime.TtySize + // SkipUserEnvProbe, when true, makes Exec bypass the merge of // probedEnv AND cfg.RemoteEnv into the process environment. // Only opts.Env (after substitution) plus whatever the runtime @@ -104,14 +115,16 @@ func (e *Engine) Exec(ctx context.Context, ws *Workspace, opts ExecOptions) (Exe start := time.Now() res, err := e.runtime.ExecContainer(ctx, ws.Container.ID, runtime.ExecOptions{ - Cmd: cmd, - Env: env, - User: user, - WorkingDir: wd, - Tty: opts.Tty, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, + Cmd: cmd, + Env: env, + User: user, + WorkingDir: wd, + Tty: opts.Tty, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + InitialTtySize: opts.InitialTtySize, + ResizeCh: opts.ResizeCh, }) if err != nil { bus.Emit(events.ExecCompletedEvent{ diff --git a/runtime/docker/exec.go b/runtime/docker/exec.go index 43d31ad..10c1810 100644 --- a/runtime/docker/exec.go +++ b/runtime/docker/exec.go @@ -12,6 +12,14 @@ import ( ) func (r *Runtime) ExecContainer(ctx context.Context, id string, opts runtime.ExecOptions) (runtime.ExecResult, error) { + var consoleSize client.ConsoleSize + if opts.Tty && opts.InitialTtySize.Width != 0 && opts.InitialTtySize.Height != 0 { + consoleSize = client.ConsoleSize{ + Height: uint(opts.InitialTtySize.Height), + Width: uint(opts.InitialTtySize.Width), + } + } + createRes, err := r.api.ExecCreate(ctx, id, client.ExecCreateOptions{ User: opts.User, WorkingDir: opts.WorkingDir, @@ -21,6 +29,7 @@ func (r *Runtime) ExecContainer(ctx context.Context, id string, opts runtime.Exe AttachStdout: true, AttachStderr: true, TTY: opts.Tty, + ConsoleSize: consoleSize, }) if err != nil { if isContainerNotFound(err) { @@ -35,6 +44,16 @@ func (r *Runtime) ExecContainer(ctx context.Context, id string, opts runtime.Exe } defer attachRes.Close() + // Forward pty resize events for the lifetime of this exec. The + // goroutine exits when the caller stops sending and the parent + // context is cancelled (which we always do via the deferred + // cancel below, even on the happy path). + if opts.Tty && opts.ResizeCh != nil { + resizeCtx, cancelResize := context.WithCancel(ctx) + defer cancelResize() + go forwardExecResize(resizeCtx, r.api, createRes.ID, opts.ResizeCh) + } + // Pipe stdin in the background if provided. We close the write half // when the caller-supplied reader returns EOF so the daemon sees a // closed stdin and the exec can finish naturally. @@ -96,3 +115,40 @@ func (r *Runtime) ExecContainer(ctx context.Context, id string, opts runtime.Exe } return res, nil } + +// forwardExecResize drains size updates and pushes them to the daemon +// for the given exec id. Coalescing isn't strictly needed (resize events +// are sparse compared to the speed of the API call), but we use a +// best-effort drain to avoid backing up if the user resizes furiously. +func forwardExecResize(ctx context.Context, api *client.Client, execID string, ch <-chan runtime.TtySize) { + for { + select { + case <-ctx.Done(): + return + case sz, ok := <-ch: + if !ok { + return + } + // Drain any pending updates and keep only the latest. + drained := true + for drained { + select { + case next, ok := <-ch: + if !ok { + return + } + sz = next + default: + drained = false + } + } + if sz.Width == 0 || sz.Height == 0 { + continue + } + _, _ = api.ExecResize(ctx, execID, client.ExecResizeOptions{ + Height: uint(sz.Height), + Width: uint(sz.Width), + }) + } + } +} diff --git a/runtime/runtime.go b/runtime/runtime.go index a8dac50..3e01975 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -455,6 +455,14 @@ type BuildSpec struct { Platform string } +// TtySize is a pty geometry in character cells. A zero value means +// "unspecified" — callers that don't care can leave it at the zero value +// and the runtime falls back to the container's default (typically 80x24). +type TtySize struct { + Width uint16 + Height uint16 +} + // ExecOptions configures an exec invocation. If Stdout/Stderr are nil, // captured output is returned in ExecResult; otherwise output streams // directly and ExecResult's Stdout/Stderr are empty. @@ -467,6 +475,19 @@ type ExecOptions struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer + + // InitialTtySize, when Tty is true and both dimensions are non-zero, + // sets the pty geometry at exec creation. Ignored when Tty is false. + InitialTtySize TtySize + + // ResizeCh, when non-nil and Tty is true, delivers pty geometry + // updates for the lifetime of the exec. The runtime forwards each + // received TtySize to the underlying exec session. The caller owns + // the channel and MUST NOT close it before ExecContainer returns. + // Sends are non-blocking from the caller's perspective: if the + // runtime is mid-resize, the latest value may be coalesced. Ignored + // when Tty is false. + ResizeCh <-chan TtySize } // ExecResult is the outcome of ExecContainer. From 29c6b21b5fe5a814df80514cd2ff11f64f6184f4 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Sat, 23 May 2026 00:10:50 -0300 Subject: [PATCH 2/2] cli: forward SIGWINCH into exec resize channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`devcontainer exec\` used to only put stdin into raw mode and forward stdio — every interactive program kept rendering at 80x24 regardless of the actual terminal size, and resizing the window did nothing. Wire the runtime resize plumbing now that it exists. setupTty grows into a ttyState struct carrying: - initial: term.GetSize at exec start → passed as InitialTtySize so the pty is created at the right geometry - resize: a channel fed by a SIGWINCH listener goroutine that emits the new size on every window-resize signal → passed as ResizeCh so the daemon resizes the pty in-flight - restore: cancels the SIGWINCH listener and restores cooked-mode stdin; always safe to call (no-op when tty is false) The goroutine bounds its lifetime on a context cancelled by restore, so it exits cleanly when Exec returns or the user Ctrl-Cs. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/devcontainer/exec.go | 92 +++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/cmd/devcontainer/exec.go b/cmd/devcontainer/exec.go index 0dc19d5..bdaf92e 100644 --- a/cmd/devcontainer/exec.go +++ b/cmd/devcontainer/exec.go @@ -1,14 +1,18 @@ package main import ( + "context" "fmt" "os" + "os/signal" "strings" + "syscall" "github.com/spf13/cobra" "golang.org/x/term" devcontainer "github.com/crunchloop/devcontainer" + "github.com/crunchloop/devcontainer/runtime" ) func newExecCmd(rf *rootFlags) *cobra.Command { @@ -56,11 +60,11 @@ func newExecCmd(rf *rootFlags) *cobra.Command { } tty := !noTty && term.IsTerminal(int(os.Stdin.Fd())) - restore, err := setupTty(tty) + ttyState, err := setupTty(ctx, tty) if err != nil { return err } - defer restore() + defer ttyState.restore() // Default cwd to the resolved container workspace folder // when --working-dir wasn't given, so `devcontainer exec ls` @@ -71,19 +75,17 @@ func newExecCmd(rf *rootFlags) *cobra.Command { wd = cfg.ContainerWorkspaceFolder } - // NOTE: window-size forwarding (SIGWINCH → resize) is not - // wired here yet — the runtime ExecOptions surface for it - // is still in-flight on main. Once it lands we can plumb - // term.GetSize + signal.Notify(SIGWINCH) through. res, err := eng.Exec(ctx, workspace, devcontainer.ExecOptions{ - Cmd: args, - Env: env, - User: user, - WorkingDir: wd, - Tty: tty, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, + Cmd: args, + Env: env, + User: user, + WorkingDir: wd, + Tty: tty, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + InitialTtySize: ttyState.initial, + ResizeCh: ttyState.resize, }) if err != nil { return err @@ -123,18 +125,68 @@ func parseEnvFlags(flags []string) (map[string]string, error) { return out, nil } -// setupTty puts the terminal in raw mode when tty is true and returns a -// restore func that's always safe to call (no-op when tty was false). -func setupTty(tty bool) (func(), error) { +// ttyState bundles the resources setupTty owns for a single exec +// invocation: the initial pty size at exec time, a channel that +// receives subsequent resizes triggered by SIGWINCH, and a restore +// function that tears it all down. restore is always safe to call, +// even when tty is false (it's a no-op in that case). +type ttyState struct { + initial runtime.TtySize + resize <-chan runtime.TtySize + restore func() +} + +// setupTty puts stdin in raw mode when tty is true and starts a +// goroutine that translates SIGWINCH into resize-channel events for +// the lifetime of the exec. The returned ttyState carries the values +// to pass through devcontainer.ExecOptions. +func setupTty(ctx context.Context, tty bool) (ttyState, error) { if !tty { - return func() {}, nil + return ttyState{restore: func() {}}, nil } fd := int(os.Stdin.Fd()) oldState, err := term.MakeRaw(fd) if err != nil { - return func() {}, fmt.Errorf("make raw: %w", err) + return ttyState{restore: func() {}}, fmt.Errorf("make raw: %w", err) + } + + var initial runtime.TtySize + if w, h, err := term.GetSize(fd); err == nil { + initial = runtime.TtySize{Width: uint16(w), Height: uint16(h)} } - return func() { _ = term.Restore(fd, oldState) }, nil + + resizeCh := make(chan runtime.TtySize, 1) + sigwinch := make(chan os.Signal, 1) + signal.Notify(sigwinch, syscall.SIGWINCH) + winchCtx, cancelWinch := context.WithCancel(ctx) + go func() { + defer signal.Stop(sigwinch) + for { + select { + case <-winchCtx.Done(): + return + case <-sigwinch: + w, h, err := term.GetSize(fd) + if err != nil { + continue + } + select { + case resizeCh <- runtime.TtySize{Width: uint16(w), Height: uint16(h)}: + case <-winchCtx.Done(): + return + } + } + } + }() + + return ttyState{ + initial: initial, + resize: resizeCh, + restore: func() { + cancelWinch() + _ = term.Restore(fd, oldState) + }, + }, nil } // silentExitError carries an exit code without a printed message.