Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 72 additions & 20 deletions cmd/devcontainer/exec.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 21 additions & 8 deletions exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down
56 changes: 56 additions & 0 deletions runtime/docker/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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),
})
}
}
}
21 changes: 21 additions & 0 deletions runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading