Skip to content

Commit 63a06cb

Browse files
authored
chore: Preventing flakes from TestNewSignalsForwarderMultipleUnix (#6750)
1 parent 42931d6 commit 63a06cb

9 files changed

Lines changed: 287 additions & 105 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
version: "v1.1.4"
3+
category: "bug-fixes"
4+
---
5+
6+
#### Fixed the signal sent to a running command during shutdown
7+
8+
On Windows, when a failure rather than Ctrl+C cancelled a run, Terragrunt crashed with a nil pointer panic instead of stopping the command it had started. It now terminates the command, which is the closest thing Windows offers to an interrupt.
9+
10+
On every platform, when a command exited on its own during the grace period after Ctrl+C, Terragrunt could still send it the signal and then log a forwarding error against a process that was already gone.

internal/os/exec/cmd.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"path/filepath"
9+
"sync"
910
"sync/atomic"
1011
"time"
1112

@@ -33,6 +34,7 @@ var ErrPTYRequiresOSBackend = errors.New("PTY allocation requires an OS-backed v
3334
type Cmd struct {
3435
vc vexec.Cmd
3536
interruptSignal os.Signal
37+
notifier signal.NotifierFunc
3638
filename string
3739
dir string
3840
forwardSignalDelay time.Duration
@@ -56,6 +58,7 @@ func Command(ctx context.Context, v *venv.Venv, name string, args ...string) *Cm
5658
vc: vc,
5759
filename: filepath.Base(name),
5860
interruptSignal: signal.InterruptSignal,
61+
notifier: signal.NotifierWithContext,
5962
}
6063

6164
cmd.SetStdin(v.Stdin)
@@ -74,10 +77,6 @@ func Command(ctx context.Context, v *venv.Venv, name string, args ...string) *Cm
7477
sig = cmd.interruptSignal
7578
}
7679

77-
if sig == nil {
78-
sig = os.Kill
79-
}
80-
8180
if err := vc.Signal(sig); err != nil && !errors.Is(err, vexec.ErrProcessNotStarted) {
8281
return err
8382
}
@@ -188,12 +187,13 @@ func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context, l log.Logger) fu
188187
// ForwardSignal forwards a given `sig` with a delay if cmd.forwardSignalDelay is greater than 0,
189188
// and if the same signal is received again, it is forwarded immediately.
190189
func (cmd *Cmd) ForwardSignal(ctx context.Context, l log.Logger, sig os.Signal) {
191-
ctxDelay, cancelDelay := context.WithCancel(ctx)
192-
defer cancelDelay()
190+
escalate := make(chan struct{})
191+
stopWaiting := sync.OnceFunc(func() { close(escalate) })
192+
193+
notifyCtx, stopNotifying := context.WithCancel(ctx)
194+
defer stopNotifying()
193195

194-
signal.NotifierWithContext(ctx, func(_ os.Signal) {
195-
cancelDelay()
196-
}, sig)
196+
cmd.notifier(notifyCtx, func(os.Signal) { stopWaiting() }, sig)
197197

198198
if cmd.forwardSignalDelay > 0 {
199199
l.Debugf("%s signal will be forwarded to %s with delay %s",
@@ -203,11 +203,14 @@ func (cmd *Cmd) ForwardSignal(ctx context.Context, l log.Logger, sig os.Signal)
203203
)
204204
}
205205

206+
// escalate is a plain channel rather than a context derived from ctx. A derived one
207+
// would leave two ready cases here when the caller cancels, and select would forward
208+
// the signal about half the time.
206209
select {
207210
case <-ctx.Done():
208211
return
209212
case <-time.After(cmd.forwardSignalDelay):
210-
case <-ctxDelay.Done():
213+
case <-escalate:
211214
}
212215

213216
cmd.SendSignal(l, sig)

internal/os/exec/cmd_unix_test.go

Lines changed: 2 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"os"
99
"path/filepath"
1010
"strconv"
11-
"strings"
1211
"testing"
1312
"time"
1413

@@ -40,23 +39,6 @@ func requireTrapReady(t *testing.T, readyPath string) {
4039
}, 10*time.Second, 10*time.Millisecond, "child never wrote the trap-ready marker")
4140
}
4241

43-
// requireInterruptCount blocks until the subprocess records that its INT trap has run
44-
// want times.
45-
func requireInterruptCount(t *testing.T, readyPath string, want int) {
46-
t.Helper()
47-
48-
require.Eventually(t, func() bool {
49-
content, err := os.ReadFile(readyPath)
50-
if err != nil {
51-
return false
52-
}
53-
54-
got, err := strconv.Atoi(strings.TrimSpace(string(content)))
55-
56-
return err == nil && got == want
57-
}, 10*time.Second, 10*time.Millisecond, "child never acknowledged interrupt %d", want)
58-
}
59-
6042
func TestExitCodeUnix(t *testing.T) {
6143
t.Parallel()
6244

@@ -106,7 +88,7 @@ func TestNewSignalsForwarderWaitUnix(t *testing.T) {
10688
readyPath,
10789
)
10890

109-
runChannel := make(chan error)
91+
runChannel := make(chan error, 1)
11092

11193
go func() {
11294
runChannel <- cmd.Run(l)
@@ -134,46 +116,6 @@ func TestNewSignalsForwarderWaitUnix(t *testing.T) {
134116
)
135117
}
136118

137-
// There isn't a proper way to catch interrupts in Windows batch scripts, so this test exists only for Unix.
138-
func TestNewSignalsForwarderMultipleUnix(t *testing.T) {
139-
t.Parallel()
140-
141-
expectedInterrupts := 4
142-
143-
l := logger.CreateLogger()
144-
145-
readyPath := filepath.Join(t.TempDir(), "sigint-ready")
146-
147-
cmd := exec.Command(
148-
t.Context(), venvtest.New().WithExec(vexec.NewOSExec()),
149-
"testdata/test_sigint_multiple.sh", strconv.Itoa(expectedInterrupts), readyPath,
150-
)
151-
152-
runChannel := make(chan error)
153-
154-
go func() {
155-
runChannel <- cmd.Run(l)
156-
}()
157-
158-
requireInterruptCount(t, readyPath, 0)
159-
160-
// Bash defers its trap until the running `sleep` returns, so two signals delivered within
161-
// one sleep window collapse into a single handler run. Waiting for the child to
162-
// acknowledge each interrupt before sending the next keeps the count exact.
163-
for interrupts := 1; interrupts <= expectedInterrupts; interrupts++ {
164-
cmd.SendSignal(l, os.Interrupt)
165-
166-
requireInterruptCount(t, readyPath, interrupts)
167-
}
168-
169-
err := <-runChannel
170-
require.Error(t, err)
171-
172-
retCode, err := util.GetExitCode(err)
173-
require.NoError(t, err)
174-
assert.Equal(t, expectedInterrupts, retCode, "Subprocess didn't receive multiple signals")
175-
}
176-
177119
// TestGracefulShutdownOnContextCancelUnix verifies that when the context is cancelled
178120
// without a signal cause, the Cancel callback sends SIGINT (not SIGKILL) to allow
179121
// processes like Terraform to gracefully shutdown their child processes.
@@ -197,7 +139,7 @@ func TestGracefulShutdownOnContextCancelUnix(t *testing.T) {
197139

198140
cmd.Configure(exec.WithGracefulShutdownDelay(5 * time.Second))
199141

200-
runChannel := make(chan error)
142+
runChannel := make(chan error, 1)
201143

202144
go func() {
203145
runChannel <- cmd.Run(l)

internal/os/exec/cmd_windows_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func TestWindowsNewSignalsForwarderWait(t *testing.T) {
8181
strconv.Itoa(expectedWait),
8282
)
8383

84-
runChannel := make(chan error)
84+
runChannel := make(chan error, 1)
8585

8686
go func() {
8787
runChannel <- cmd.Run(l)

internal/os/exec/opts.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package exec
33
import (
44
"time"
55

6+
"github.com/gruntwork-io/terragrunt/internal/os/signal"
67
"github.com/gruntwork-io/terragrunt/internal/venv"
78
)
89

@@ -30,6 +31,14 @@ func WithForwardSignalDelay(delay time.Duration) Option {
3031
}
3132
}
3233

34+
// WithSignalNotifier sets the source [Cmd.ForwardSignal] watches for a repeat of the
35+
// signal it is holding. The default watches the OS.
36+
func WithSignalNotifier(notifier signal.NotifierFunc) Option {
37+
return func(cmd *Cmd) {
38+
cmd.notifier = notifier
39+
}
40+
}
41+
3342
// WithGracefulShutdownDelay sets the time to wait for a process to exit gracefully
3443
// after sending an interrupt signal before escalating to SIGKILL.
3544
// This allows processes like Terraform to clean up child processes (e.g., provider plugins).

0 commit comments

Comments
 (0)