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