Skip to content
Open
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
38 changes: 37 additions & 1 deletion cmd/crossplane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ limitations under the License.
package main

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"

"github.com/alecthomas/kong"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/term"
"github.com/spf13/afero"
"github.com/willabides/kongplete"
Expand Down Expand Up @@ -159,13 +163,45 @@ func main() {

// Set up a spinner printer for commands to use. This helps ensure output
// consistency across commands.
sp := terminal.NewSpinnerPrinter(os.Stderr, term.IsTerminal(os.Stderr.Fd()))
tty := term.IsTerminal(os.Stderr.Fd())
sp := terminal.NewSpinnerPrinter(os.Stderr, tty)
ctx.BindTo(sp, (*terminal.SpinnerPrinter)(nil))

// Cancel the context on the first SIGINT or SIGTERM so in-flight work
// unwinds and deferred cleanup (e.g. render's containers and network)
// runs. A second signal exits immediately without waiting for cleanup.
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
stopExitOnSignal := context.AfterFunc(sigCtx, func() { exitOnSignal(tty) })
ctx.BindTo(sigCtx, (*context.Context)(nil))

err = ctx.Run()
interrupted := sigCtx.Err() != nil
stopExitOnSignal()
stop()
if interrupted {
// Exit with the conventional 128+SIGINT status.
if err != nil {
fmt.Fprintf(os.Stderr, "crossplane: %v\n", err)
}
os.Exit(130)
}
ctx.FatalIfErrorf(err)
}

// exitOnSignal waits for SIGINT or SIGTERM, then exits with status 130
// without waiting for cleanup. A spinner may still own the terminal, so when
// stderr is a terminal it first shows the cursor and disables bracketed
// paste, which would otherwise be left hidden and enabled.
func exitOnSignal(tty bool) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
if tty {
_, _ = fmt.Fprint(os.Stderr, ansi.ShowCursor+ansi.ResetModeBracketedPaste)
}
os.Exit(130)
}

// configFlag scans argv for the --config flag and returns its value or "" if
// the config flag is not present.
func configFlag(args []string) (string, error) {
Expand Down
4 changes: 2 additions & 2 deletions cmd/crossplane/render/op/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ func (c *Cmd) AfterApply() error {
}

// Run alpha render op.
func (c *Cmd) Run(k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocognit // Orchestration is inherently complex.
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
func (c *Cmd) Run(baseCtx context.Context, k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocognit // Orchestration is inherently complex.
ctx, cancel := context.WithTimeout(baseCtx, c.Timeout)
defer cancel()

// Load operation (extracts Operation template from CronOperation/WatchOperation)
Expand Down
2 changes: 1 addition & 1 deletion cmd/crossplane/render/op/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ func TestCmdRun(t *testing.T) {
buf := &bytes.Buffer{}
kctx := &kong.Context{Kong: &kong.Kong{Stdout: buf, Stderr: io.Discard}}

err := tc.args.cmd.Run(kctx, logging.NewNopLogger(), terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{})
err := tc.args.cmd.Run(t.Context(), kctx, logging.NewNopLogger(), terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{})
if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/crossplane/render/xr/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ func (c *Cmd) AfterApply() error {
}

// Run render.
func (c *Cmd) Run(k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocognit // Orchestration is inherently complex.
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
func (c *Cmd) Run(baseCtx context.Context, k *kong.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocognit // Orchestration is inherently complex.
ctx, cancel := context.WithTimeout(baseCtx, c.Timeout)
defer cancel()

xr, err := render.LoadCompositeResource(c.fs, c.CompositeResource)
Expand Down
2 changes: 1 addition & 1 deletion cmd/crossplane/render/xr/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ func TestCmdRun(t *testing.T) {
buf := &bytes.Buffer{}
kctx := &kong.Context{Kong: &kong.Kong{Stdout: buf, Stderr: io.Discard}}

err := tc.args.cmd.Run(kctx, logging.NewNopLogger(), terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{})
err := tc.args.cmd.Run(t.Context(), kctx, logging.NewNopLogger(), terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{})
if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
dario.cat/mergo v1.0.2
github.com/Masterminds/semver/v3 v3.5.0
github.com/alecthomas/kong v1.16.1
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/term v0.2.2
github.com/containerd/errdefs v1.0.0
github.com/crossplane/crossplane-runtime/v2 v2.4.0
Expand Down Expand Up @@ -110,7 +111,6 @@ require (
github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
Expand Down
7 changes: 7 additions & 0 deletions internal/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,10 @@ func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) (
return nil, nil, err
}
defer attach.Close()
// StdCopy below doesn't observe ctx, so close the attach connection on
// cancellation to unblock it rather than waiting for the container to exit.
stopClose := context.AfterFunc(ctx, func() { attach.Close() })
defer stopClose()

// Write stdin data if provided, then close the write side so the
// container sees EOF.
Expand All @@ -478,6 +482,9 @@ func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) (

var stdout, stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil {
if ctx.Err() != nil {
return nil, nil, errors.Wrap(ctx.Err(), "failed to read container output")
}
return nil, nil, errors.Wrap(err, "failed to read container output")
}

Expand Down
28 changes: 9 additions & 19 deletions internal/terminal/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"

bspinner "charm.land/bubbles/v2/spinner"
Expand Down Expand Up @@ -283,24 +281,16 @@ func (m *MultiSpinner) Start() {
tea.WithOutput(m.out),
)

go runProgramWithSignalHandler(m.program)
go runProgram(m.program)
}

func runProgramWithSignalHandler(p *tea.Program) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer func() {
signal.Stop(sigCh)
close(sigCh)
}()
go func() {
_, ok := <-sigCh
if ok {
_ = p.ReleaseTerminal()
os.Exit(130)
}
}()

// runProgram runs p until it quits. It doesn't handle signals: main's
// signal.NotifyContext does that once for all commands by cancelling the
// command context. Spinner lifetime is handled once by the SpinnerPrinter
// wrappers, which start the spinner, run the wrapped func, then stop it, so a
// spinner stops when cancellation makes the wrapped func return. Exiting here
// would skip the command's deferred cleanup.
func runProgram(p *tea.Program) {
_, _ = p.Run()
}

Expand Down Expand Up @@ -429,7 +419,7 @@ func (ss *SuccessSpinner) Start() {
tea.WithoutSignalHandler(),
)

go runProgramWithSignalHandler(ss.program)
go runProgram(ss.program)
}

func (ss *SuccessSpinner) stop() {
Expand Down
Loading