From ed2e4cc226908c20e1f8a313bf32698330174dbe Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Wed, 29 Jul 2026 14:10:22 +0300 Subject: [PATCH 1/2] Fix self-upgrade interrupt handling --- docs/docs/changelog.md | 1 + internal/cli/cli.go | 20 ++++++++++++++++---- internal/cli/cli_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index af613879..e4e7e066 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -8,6 +8,7 @@ title: Changelog * `[Added]` Add `checksum.files`, `checksum.sh`, and `checksum.persist` command checksum syntax while keeping the old checksum format compatible. * `[Added]` Add `lets self upgrade --pre` to opt into upgrading to the latest prerelease. * `[Added]` Add `lets self fix` config migration command with `--dry-run` preview output for deprecated checksum syntax. +* `[Fixed]` Allow `lets self upgrade` downloads to respond to Ctrl-C. * `[Fixed]` Make checksum calculation respect command-level `work_dir` overrides. * `[Fixed]` Restore the release checkout after the GoReleaser dry run so prerelease publishing does not fail on a dirty `go.mod`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 680a02e3..7a4e054b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -159,12 +159,24 @@ func getContext() context.Context { ch := make(chan os.Signal, 1) signal.Notify(ch, os.Interrupt, syscall.SIGTERM) - ctx, cancel := context.WithCancel(context.Background()) + return signalContext(context.Background(), ch, func() { + signal.Stop(ch) + }) +} + +func signalContext(parent context.Context, ch <-chan os.Signal, stop func()) context.Context { + ctx, cancel := context.WithCancel(parent) go func() { - sig := <-ch - log.Printf("signal received: %s", sig) - cancel() + defer stop() + + select { + case sig := <-ch: + log.Printf("signal received: %s", sig) + cancel() + case <-parent.Done(): + cancel() + } }() return ctx diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6fda4656..0d422fed 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,7 +1,10 @@ package cli import ( + "context" + "os" "testing" + "time" cmdpkg "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/settings" @@ -68,6 +71,43 @@ func TestFailOnConfigError(t *testing.T) { } } +func TestSignalContext(t *testing.T) { + t.Run("cancels and stops signal notification after first signal", func(t *testing.T) { + signals := make(chan os.Signal, 1) + stopped := make(chan struct{}) + ctx := signalContext(context.Background(), signals, func() { + close(stopped) + }) + + signals <- os.Interrupt + + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("expected signal context to be canceled") + } + + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("expected signal notification to stop") + } + }) + + t.Run("cancels when parent context is canceled", func(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + ctx := signalContext(parent, make(chan os.Signal), func() {}) + + cancel() + + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("expected signal context to be canceled by parent") + } + }) +} + func TestShouldCheckForUpdate(t *testing.T) { defaultSettings := settings.Default() From acbd42c9d37a6ba55af5a8bc73ef0f75d3c6dce5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Wed, 29 Jul 2026 15:44:04 +0300 Subject: [PATCH 2/2] Test signal cleanup on parent cancellation --- internal/cli/cli_test.go | 48 ++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0d422fed..ae32eb88 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -72,6 +72,16 @@ func TestFailOnConfigError(t *testing.T) { } func TestSignalContext(t *testing.T) { + assertClosed := func(t *testing.T, ch <-chan struct{}, message string) { + t.Helper() + + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal(message) + } + } + t.Run("cancels and stops signal notification after first signal", func(t *testing.T) { signals := make(chan os.Signal, 1) stopped := make(chan struct{}) @@ -81,30 +91,34 @@ func TestSignalContext(t *testing.T) { signals <- os.Interrupt - select { - case <-ctx.Done(): - case <-time.After(time.Second): - t.Fatal("expected signal context to be canceled") - } - - select { - case <-stopped: - case <-time.After(time.Second): - t.Fatal("expected signal notification to stop") - } + assertClosed(t, ctx.Done(), "expected signal context to be canceled") + assertClosed(t, stopped, "expected signal notification to stop") }) t.Run("cancels when parent context is canceled", func(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) - ctx := signalContext(parent, make(chan os.Signal), func() {}) + stopped := make(chan struct{}) + ctx := signalContext(parent, make(chan os.Signal), func() { + close(stopped) + }) cancel() - select { - case <-ctx.Done(): - case <-time.After(time.Second): - t.Fatal("expected signal context to be canceled by parent") - } + assertClosed(t, ctx.Done(), "expected signal context to be canceled by parent") + assertClosed(t, stopped, "expected signal notification to stop") + }) + + t.Run("cancels when parent context is already canceled", func(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + cancel() + + stopped := make(chan struct{}) + ctx := signalContext(parent, make(chan os.Signal), func() { + close(stopped) + }) + + assertClosed(t, ctx.Done(), "expected signal context to be canceled by parent") + assertClosed(t, stopped, "expected signal notification to stop") }) }