Skip to content

fix: use context.Cause for interrupt detection#53

Merged
min0625 merged 1 commit into
mainfrom
fix/signal-cause-context
Jul 2, 2026
Merged

fix: use context.Cause for interrupt detection#53
min0625 merged 1 commit into
mainfrom
fix/signal-cause-context

Conversation

@min0625

@min0625 min0625 commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • signal.NotifyContext cancels the context with a private signalError cause, not context.Canceled, so errors.Is(err, context.Canceled) never matched the error net/http surfaces (which wraps context.Cause(ctx)) — an interrupted request could be misreported as a generic error (exit 1) instead of a clean interrupt (exit 130). Compare against context.Cause(ctx) instead.
  • Extracted posixLocale constant for the "POSIX" locale sentinel, reused in getSystemLanguage and its tests.
  • Generalized captureStdout into captureOutput (parameterized by **os.File) and added captureStderr, with t.Cleanup as a safety net if a test fails before calling the returned flush function.
  • Added TestRunInterrupted, which drives run() against a held-open test server and sends real SIGINT/SIGTERM signals to confirm both cancel the in-flight request and exit with code 130 and no stderr output.

Test plan

  • go test -race -failfast -count=1 ./...
  • make lint

…coverage

signal.NotifyContext sets a private signalError as the cancellation cause
rather than context.Canceled, so errors.Is(err, context.Canceled) never
matched the error net/http surfaces on interrupt. Compare against
context.Cause(ctx) instead, and add a test exercising both SIGINT and
SIGTERM against an in-flight request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix interrupt detection using context.Cause and add signal tests

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Detect user interrupts by comparing command errors to context.Cause(ctx).
• Extract POSIX locale sentinel into a shared constant.
• Add SIGINT/SIGTERM integration test and improve stdout/stderr capture helpers.
Diagram

graph TD
  A["run()"] --> B["signal.NotifyContext"] --> C["ctx (with cause)"] --> D["ExecuteContext()"] --> E["net/http request"]
  F["SIGINT/SIGTERM"] --> B --> C
  D --> G{"err matches ctx cause?"} --> H["exit 130"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Treat any ctx.Done() as interrupt (ctx.Err() != nil)
  • ➕ Very simple implementation
  • ➕ No dependency on how the error is wrapped
  • ➖ Can misclassify unrelated command errors as clean interrupts if a signal arrives concurrently
  • ➖ Loses fidelity about why the command failed
2. Match against context.Canceled / context.DeadlineExceeded only
  • ➕ Common convention in Go cancellation handling
  • ➕ Avoids coupling to specific error values
  • ➖ Does not work with signal.NotifyContext because its cancellation cause is a private signalError
  • ➖ Still risks false negatives when net/http wraps context.Cause(ctx)
3. Plumb an explicit interrupt flag from the signal handler into run()
  • ➕ Decouples interrupt classification from error wrapping behavior
  • ➕ Can be made very explicit/robust
  • ➖ More plumbing and state; increases complexity for little gain
  • ➖ Still needs care to avoid races with normal failures

Recommendation: Keep the current approach: matching the returned error against context.Cause(ctx) is the most correct and least invasive way to align with net/http’s error wrapping on cancellation while also avoiding the false-positive classification risk of a plain ctx.Done()/ctx.Err() check.

Files changed (2) +126 / -23

Bug fix (1) +14 / -2
main.goDetect interrupts via context.Cause and reuse POSIX locale constant +14/-2

Detect interrupts via context.Cause and reuse POSIX locale constant

• Changes interrupt handling in run() to compare the command error against context.Cause(ctx), accounting for signal.NotifyContext using a private cancellation cause surfaced by net/http. Extracts a posixLocale constant and reuses it in getSystemLanguage() when skipping non-language locales.

cmd/mint/main.go

Tests (1) +112 / -21
main_test.goAdd SIGINT/SIGTERM interrupt test and refactor output capture helpers +112/-21

Add SIGINT/SIGTERM interrupt test and refactor output capture helpers

• Refactors stdout capture into a generalized captureOutput helper, adds captureStderr, and uses t.Cleanup to restore streams even on early test failure. Adds TestRunInterrupted which runs an in-flight request against a held-open test server, sends SIGINT/SIGTERM to the process, and asserts exit code 130 with no stderr output.

cmd/mint/main_test.go

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 36 rules

Grey Divider


Action required

1. Signal test breaks Windows 🐞 Bug ☼ Reliability
Description
TestRunInterrupted sends SIGINT/SIGTERM to the current process via syscall.Kill, which is
Unix-specific and is likely to fail on Windows (compile-time missing/unsupported symbols or runtime
not-supported behavior). This is a regression because the repo’s release configuration explicitly
targets Windows builds.
Code

cmd/mint/main_test.go[R690-742]

+// TestRunInterrupted covers both signals run() registers via
+// signal.NotifyContext (os.Interrupt and syscall.SIGTERM): either one must
+// cancel an in-flight request and exit quietly with code 130.
+func TestRunInterrupted(t *testing.T) {
+	signals := []struct {
+		name string
+		sig  syscall.Signal
+	}{
+		{"SIGINT", syscall.SIGINT},
+		{"SIGTERM", syscall.SIGTERM},
	}

-	oldErr := os.Stderr
+	for _, tt := range signals {
+		t.Run(tt.name, func(t *testing.T) {
+			started := make(chan struct{})
+			done := make(chan struct{})

-	os.Stderr = ww
-	defer func() {
-		_ = ww.Close()
+			srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+				close(started)
+				<-done // held open until the test releases it, regardless of client-side cancellation
+			}))

-		os.Stderr = oldErr
-		_, _ = io.Copy(io.Discard, rr)
-		_ = rr.Close()
-	}()
+			defer func() {
+				close(done)
+				srv.Close()
+			}()

-	if code := run(); code != 1 {
-		t.Errorf("expected exit code 1, got %d", code)
+			t.Setenv("MINT_PROVIDER", "openai")
+			t.Setenv("MINT_API_KEY", "test")
+			t.Setenv("MINT_BASE_URL", srv.URL)
+			t.Setenv("MINT_MODEL_NAME", "test-model")
+
+			old := os.Args
+
+			os.Args = []string{"mint", "--target", "en", "hello"}
+			defer func() { os.Args = old }()
+
+			flushOut := captureStdout(t)
+			flushErr := captureStderr(t)
+
+			codeCh := make(chan int, 1)
+
+			go func() { codeCh <- run() }()
+
+			select {
+			case <-started:
+			case <-time.After(5 * time.Second):
+				t.Fatal("request never reached the server")
+			}
+
+			if err := syscall.Kill(os.Getpid(), tt.sig); err != nil {
+				t.Fatalf("failed to send %s: %v", tt.name, err)
+			}
Evidence
The test explicitly uses Unix signals and syscall.Kill to signal the current process; this is
inherently non-portable. The project configuration explicitly declares Windows as a supported build
target, making Windows test compatibility relevant.

cmd/mint/main_test.go[690-762]
.goreleaser.yaml[5-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`TestRunInterrupted` uses `syscall.Signal` constants (including `SIGTERM`) and `syscall.Kill` to send real signals to the test process. This is not portable and will fail on Windows, even though the project targets Windows releases.

### Issue Context
The repository’s GoReleaser config includes `windows` in the supported `goos` list. Tests should either be portable or correctly OS-gated.

### Fix Focus Areas
- cmd/mint/main_test.go[690-764]
- .goreleaser.yaml[5-22]

### Suggested fix
Do one of:
1) Move `TestRunInterrupted` into a new file with build tags, e.g. `cmd/mint/run_interrupted_unix_test.go`:
  - Add `//go:build !windows` (or `//go:build unix`) at the top.
  - Keep the rest of `main_test.go` portable.

2) Add an early skip inside the test:
  - `if runtime.GOOS == "windows" { t.Skip("signal delivery via syscall.Kill not supported on Windows") }`
  - If you still want partial coverage on Windows, you can run only the `os.Interrupt` case and skip `SIGTERM`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread cmd/mint/main_test.go
Comment on lines +690 to +742
// TestRunInterrupted covers both signals run() registers via
// signal.NotifyContext (os.Interrupt and syscall.SIGTERM): either one must
// cancel an in-flight request and exit quietly with code 130.
func TestRunInterrupted(t *testing.T) {
signals := []struct {
name string
sig syscall.Signal
}{
{"SIGINT", syscall.SIGINT},
{"SIGTERM", syscall.SIGTERM},
}

oldErr := os.Stderr
for _, tt := range signals {
t.Run(tt.name, func(t *testing.T) {
started := make(chan struct{})
done := make(chan struct{})

os.Stderr = ww
defer func() {
_ = ww.Close()
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
close(started)
<-done // held open until the test releases it, regardless of client-side cancellation
}))

os.Stderr = oldErr
_, _ = io.Copy(io.Discard, rr)
_ = rr.Close()
}()
defer func() {
close(done)
srv.Close()
}()

if code := run(); code != 1 {
t.Errorf("expected exit code 1, got %d", code)
t.Setenv("MINT_PROVIDER", "openai")
t.Setenv("MINT_API_KEY", "test")
t.Setenv("MINT_BASE_URL", srv.URL)
t.Setenv("MINT_MODEL_NAME", "test-model")

old := os.Args

os.Args = []string{"mint", "--target", "en", "hello"}
defer func() { os.Args = old }()

flushOut := captureStdout(t)
flushErr := captureStderr(t)

codeCh := make(chan int, 1)

go func() { codeCh <- run() }()

select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("request never reached the server")
}

if err := syscall.Kill(os.Getpid(), tt.sig); err != nil {
t.Fatalf("failed to send %s: %v", tt.name, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Signal test breaks windows 🐞 Bug ☼ Reliability

TestRunInterrupted sends SIGINT/SIGTERM to the current process via syscall.Kill, which is
Unix-specific and is likely to fail on Windows (compile-time missing/unsupported symbols or runtime
not-supported behavior). This is a regression because the repo’s release configuration explicitly
targets Windows builds.
Agent Prompt
### Issue description
`TestRunInterrupted` uses `syscall.Signal` constants (including `SIGTERM`) and `syscall.Kill` to send real signals to the test process. This is not portable and will fail on Windows, even though the project targets Windows releases.

### Issue Context
The repository’s GoReleaser config includes `windows` in the supported `goos` list. Tests should either be portable or correctly OS-gated.

### Fix Focus Areas
- cmd/mint/main_test.go[690-764]
- .goreleaser.yaml[5-22]

### Suggested fix
Do one of:
1) Move `TestRunInterrupted` into a new file with build tags, e.g. `cmd/mint/run_interrupted_unix_test.go`:
   - Add `//go:build !windows` (or `//go:build unix`) at the top.
   - Keep the rest of `main_test.go` portable.

2) Add an early skip inside the test:
   - `if runtime.GOOS == "windows" { t.Skip("signal delivery via syscall.Kill not supported on Windows") }`
   - If you still want partial coverage on Windows, you can run only the `os.Interrupt` case and skip `SIGTERM`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@min0625 min0625 merged commit 47ad887 into main Jul 2, 2026
6 checks passed
@min0625 min0625 deleted the fix/signal-cause-context branch July 2, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant