-
Notifications
You must be signed in to change notification settings - Fork 207
localenv: reap uv subprocesses on SIGINT/SIGTERM instead of orphaning them #6107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f00995
37c82f4
6057b05
d2f66e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| //go:build unix | ||
|
|
||
| // The second-signal escape hatch is a Unix signal-delivery behavior: the test | ||
| // re-execs itself and sends SIGINT/SIGTERM, which os/signal does not support on | ||
| // Windows (the CLI's own signal handling there is likewise a no-op path). | ||
| package environments | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "context" | ||
| "os" | ||
| "os/exec" | ||
| "strings" | ||
| "syscall" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // The second-signal behavior can only be observed in a real process: signal | ||
| // dispositions are process-wide, and the test binary's own handlers would mask | ||
| // them. So the test re-executes itself as a child (Go resets dispositions to | ||
| // SIG_DFL across exec, unlike a shell background job, which inherits SIGINT as | ||
| // SIG_IGN and would silently invalidate the result). | ||
| const signalChildEnv = "TEST_ENVIRONMENTS_SIGNAL_CHILD" | ||
|
|
||
| // TestMain runs the signal-handler-under-test when re-executed as the child. | ||
| func TestMain(m *testing.M) { | ||
| if os.Getenv(signalChildEnv) == "" { | ||
| os.Exit(m.Run()) | ||
| } | ||
|
|
||
| // TestMain has no *testing.T, so t.Context() is unavailable here. | ||
| //nolint:gocritic | ||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| defer cancel() | ||
| stop := watchInterruptSignals(ctx, cancel) | ||
| defer stop() | ||
|
|
||
| // Stand in for the pipeline still draining the uv process group during the | ||
| // SIGKILL grace window — the interval in which the user needs the hatch. | ||
| os.Stdout.WriteString("READY\n") | ||
| <-ctx.Done() | ||
| os.Stdout.WriteString("CANCELLED\n") | ||
| time.Sleep(30 * time.Second) | ||
| os.Stdout.WriteString("SURVIVED\n") | ||
| os.Exit(0) | ||
| } | ||
|
|
||
| // TestWatchInterruptSignalsSecondSignalStillKills is the regression test for the | ||
| // escape hatch: signal.Notify disables the default SIGINT/SIGTERM disposition | ||
| // process-wide, so a handler that only relays the first signal leaves the user | ||
| // unable to abort. The first signal must cancel the context, and the second must | ||
| // terminate the process outright. | ||
| func TestWatchInterruptSignalsSecondSignalStillKills(t *testing.T) { | ||
| for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM} { | ||
| t.Run(sig.String(), func(t *testing.T) { | ||
| cmd := exec.Command(os.Args[0], "-test.run=TestWatchInterruptSignalsSecondSignalStillKills") | ||
| cmd.Env = append(os.Environ(), signalChildEnv+"=1") | ||
| stdout, err := cmd.StdoutPipe() | ||
| require.NoError(t, err) | ||
| require.NoError(t, cmd.Start()) | ||
| t.Cleanup(func() { _ = cmd.Process.Kill() }) | ||
|
|
||
| lines := make(chan string, 8) | ||
| go func() { | ||
| sc := bufio.NewScanner(stdout) | ||
| for sc.Scan() { | ||
| lines <- sc.Text() | ||
| } | ||
| close(lines) | ||
| }() | ||
| await := func(want string) bool { | ||
| deadline := time.After(30 * time.Second) | ||
| for { | ||
| select { | ||
| case l, ok := <-lines: | ||
| if !ok { | ||
| return false | ||
| } | ||
| if strings.Contains(l, want) { | ||
| return true | ||
| } | ||
| case <-deadline: | ||
| return false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| require.True(t, await("READY"), "child never started") | ||
| require.NoError(t, cmd.Process.Signal(sig)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocker (CI red) — this test cannot pass on Windows, and it is failing both windows cells right now.
// TODO(rsc): Handle Interrupt too?
return syscall.Errno(syscall.EWINDOWS)So Both The behavior under test is inherently POSIX (SIG_DFL dispositions, process groups), so the fix is just a build constraint. The file already sits next to //go:build unixand rename to One thing to watch when you do:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified. Confirmed the exclusion via |
||
| require.True(t, await("CANCELLED"), "first %s did not cancel the context", sig) | ||
|
|
||
| // The second signal must reach the default disposition and kill the | ||
| // child rather than being buffered and dropped by the handler. | ||
| require.NoError(t, cmd.Process.Signal(sig)) | ||
| waitErr := make(chan error, 1) | ||
| go func() { waitErr <- cmd.Wait() }() | ||
| select { | ||
| case err := <-waitErr: | ||
| // Killed by the signal, so a non-nil (non-exit-zero) error. | ||
| assert.Error(t, err, "child should die by signal, not exit cleanly") | ||
| case <-time.After(15 * time.Second): | ||
| t.Fatalf("second %s was swallowed: the user has no escape hatch "+ | ||
| "during the process group's SIGKILL grace window", sig) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestWatchInterruptSignalsStopsWithoutSignal covers the no-signal path: stop() | ||
| // must return (joining its goroutine) rather than leaving it parked on the | ||
| // channel for the life of the process. | ||
| func TestWatchInterruptSignalsStopsWithoutSignal(t *testing.T) { | ||
| ctx, cancel := context.WithCancel(t.Context()) | ||
| defer cancel() | ||
| stop := watchInterruptSignals(ctx, cancel) | ||
|
|
||
| returned := make(chan struct{}) | ||
| go func() { | ||
| stop() | ||
| close(returned) | ||
| }() | ||
| select { | ||
| case <-returned: | ||
| case <-time.After(10 * time.Second): | ||
| t.Fatal("stop() blocked: the signal goroutine was never joined") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocker (CI red) —
lintis failing on this line and on line 110 for the repo's own ruleguard rule:The rule is
NoContextBackgroundinlibs/gorules/rule_context_background.go, which exempts onlymain.go. These are the only two lint errors in the run.Line 110 (
TestWatchInterruptSignalsStopsWithoutSignal) is a straight swap tot.Context().Line 30 is the interesting one: it is inside
TestMain, where there is no*testing.T, sot.Context()is not available and the rule has no legitimate escape hatch. Simplest resolution is//nolint:gocriticwith a one-line reason (notinTestMain) — or, if you prefer to avoid the suppression,context.WithCancel(context.TODO())reads worse, so I would take the nolint.Worth running
./task lint-qbefore the next push; it would have caught both.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. Line 110's test has a
*testing.T, so it now usest.Context();TestMain(line 37) has no T, so it keepscontext.Background()with a//nolint:gocriticand a reason.golangci-linton cmd/environments is clean now. (6057b05)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified —
./task lint-qreports0 issueson the changed packages, andlintis green in CI. Right split:t.Context()where a T exists, nolint with a stated reason only inTestMainwhere the rule has no legitimate escape hatch.