diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index 1afd29ba..bd2dd449 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -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" @@ -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) { diff --git a/cmd/crossplane/render/op/cmd.go b/cmd/crossplane/render/op/cmd.go index c67a7853..065eb6a8 100644 --- a/cmd/crossplane/render/op/cmd.go +++ b/cmd/crossplane/render/op/cmd.go @@ -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) diff --git a/cmd/crossplane/render/op/cmd_test.go b/cmd/crossplane/render/op/cmd_test.go index ef8564a7..ce8614b4 100644 --- a/cmd/crossplane/render/op/cmd_test.go +++ b/cmd/crossplane/render/op/cmd_test.go @@ -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) } diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index da3302bc..10cd4332 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -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) diff --git a/cmd/crossplane/render/xr/cmd_test.go b/cmd/crossplane/render/xr/cmd_test.go index 9a98bae7..65316d8c 100644 --- a/cmd/crossplane/render/xr/cmd_test.go +++ b/cmd/crossplane/render/xr/cmd_test.go @@ -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) } diff --git a/go.mod b/go.mod index bfabe1df..db51dedd 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 0aab565d..40d83950 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -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. @@ -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") } diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go index 49ed5ccd..a3fcf54d 100644 --- a/internal/terminal/spinner.go +++ b/internal/terminal/spinner.go @@ -21,10 +21,8 @@ import ( "fmt" "io" "os" - "os/signal" "strings" "sync" - "syscall" "time" bspinner "charm.land/bubbles/v2/spinner" @@ -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() } @@ -429,7 +419,7 @@ func (ss *SuccessSpinner) Start() { tea.WithoutSignalHandler(), ) - go runProgramWithSignalHandler(ss.program) + go runProgram(ss.program) } func (ss *SuccessSpinner) stop() {