Repository: gruntwork-io/terragrunt (language: go)

Issue:
Send ctrl+c one time but terragrunt send twice to terraform
Sorry that might be a stupid question.  When I abort the apply when resource is being created via ctrl+c one time(want it to be gracefully shutdown), terragrunt send twice to terraform so there is no gracefully shutdown and result in state lock not being released and the hash value in the table would be mismatch even I run force-unlock.  May I know is it the design or only my laptop encounter this issue.



```
^C
Interrupt received.
Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...


Two interrupts received. Exiting immediately. Note that data loss may have
occurred.

╷
│ Error: operation canceled
```


terragrunt version v0.37.1
Terraform v1.2.1
on darwin_amd64
+ provider registry.terraform.io/hashicorp/aws v4.15.1
+ provider registry.terraform.io/hashicorp/cloudinit v2.2.0
+ provider registry.terraform.io/hashicorp/kubernetes v2.11.0
+ provider registry.terraform.io/hashicorp/local v2.2.3
+ provider registry.terraform.io/hashicorp/tls v3.4.0


Discussion / hints:
I've experienced the same issue several times.

terragrunt version v0.36.7 on linux
I also experience this. terragrunt version 0.36.10, terraform version 1.1.9

I see this in both VS Code terminal and Windows terminal. I'm using `zsh` in both places, and the underlying system is Ubuntu 2004 on amd64.
Terragrunt v0.39.0
Terraform v1.3.1
darwin_arm64
Still happening for me.
Terragrunt: v0.42.2
Terraform: v1.3.6
OSX:  12.6.1
Ctrl-C sends a SIGINT to all processes in the process group (terragrunt, terraform, etc), terragrunt's signal forwarding causes a second SIGINT to the child process:
https://github.com/gruntwork-io/terragrunt/blob/c85212fe2dc1bfb524396d08caca69f9124057e8/shell/run_shell_cmd.go#L178-L198

`kill -SIGINT $PID` works as expected. (one SIGINT, forwarded by the terragrunt process)

https://unix.stackexchange.com/a/149756
To workaround this issue, we leverage the `terraform_binary` configuration option to use a script that puts the Terraform invocation into a new process group. This prevents the Ctrl+C SIGINT from the terminal being sent to the Terraform command. Perhaps a similar behaviour is needed within the command exec invocation inside Terragrunt?

Fix patch:
diff --git a/shell/run_shell_cmd.go b/shell/run_shell_cmd.go
index 97b7e04cb7..34f4d5637f 100644
--- a/shell/run_shell_cmd.go
+++ b/shell/run_shell_cmd.go
@@ -10,6 +10,7 @@ import (
 	"reflect"
 	"strings"
 	"syscall"
+	"time"
 
 	"github.com/gruntwork-io/terragrunt/errors"
 	"github.com/gruntwork-io/terragrunt/options"
@@ -18,6 +19,12 @@ import (
 	"github.com/sirupsen/logrus"
 )
 
+// The signal can be sent to the main process (only `terragrunt`) as well as the process group (`terragrunt` and `terraform`), for example:
+// kill -INT <pid>  # sends SIGINT only to the main process
+// kill -INT -<pid> # sends SIGINT to the process group
+// Since we cannot know how the signal is sent, we should give `terraform` time to gracefully exit if it receives the signal directly from the shell, to avoid sending the second interrupt signal to `terraform`.
+const signalForwardingDelay = time.Second * 30
+
 // Commands that implement a REPL need a pseudo TTY when run as a subprocess in order for the readline properties to be
 // preserved. This is a list of terraform commands that have this property, which is used to determine if terragrunt
 // should allocate a ptty when running that terraform command.
@@ -195,14 +202,22 @@ func NewSignalsForwarder(signals []os.Signal, c *exec.Cmd, logger *logrus.Entry,
 		for {
 			select {
 			case s := <-signalChannel:
-				logger.Debugf("Forward signal %v to terraform.", s)
-				err := c.Process.Signal(s)
-				if err != nil {
-					logger.Errorf("Error forwarding signal: %v", err)
+				logger.Debugf("%s signal received. Gracefully shutting down... (it can take up to %v)", strings.Title(s.String()), signalForwardingDelay)
+
+				select {
+				case <-time.After(signalForwardingDelay):
+					logger.Debugf("Forward signal %v to terraform.", s)
+					err := c.Process.Signal(s)
+					if err != nil {
+						logger.Errorf("Error forwarding signal: %v", err)
+					}
+				case <-cmdChannel:
+					return
 				}
 			case <-cmdChannel:
 				return
 			}
+
 		}
 	}()
 


Test patch:
diff --git a/shell/run_shell_cmd_unix_test.go b/shell/run_shell_cmd_unix_test.go
index 352731e33b..cc82a21313 100644
--- a/shell/run_shell_cmd_unix_test.go
+++ b/shell/run_shell_cmd_unix_test.go
@@ -5,9 +5,11 @@ package shell
 
 import (
 	goerrors "errors"
+	"fmt"
 	"os"
 	"os/exec"
 	"strconv"
+	"syscall"
 	"testing"
 	"time"
 
@@ -119,3 +121,25 @@ func TestNewSignalsForwarderMultipleUnix(t *testing.T) {
 	assert.True(t, retCode <= interrupts, "Subprocess received wrong number of signals")
 	assert.Equal(t, retCode, expectedInterrupts, "Subprocess didn't receive multiple signals")
 }
+
+func TestRunShellCommandWithOutputInterrupt(t *testing.T) {
+	t.Parallel()
+
+	terragruntOptions, err := options.NewTerragruntOptionsForTest("")
+	assert.Nil(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err)
+
+	errCh := make(chan error)
+	expectedWait := 5
+
+	go func() {
+		_, err := RunShellCommandWithOutput(terragruntOptions, "", false, false, "../testdata/test_sigint_wait.sh", strconv.Itoa(expectedWait))
+		errCh <- err
+	}()
+
+	time.AfterFunc(3*time.Second, func() {
+		syscall.Kill(os.Getpid(), syscall.SIGINT)
+	})
+
+	expectedErr := fmt.Sprintf("exit status %d", expectedWait)
+	assert.EqualError(t, <-errCh, expectedErr)
+}
diff --git a/shell/run_shell_cmd_windows_test.go b/shell/run_shell_cmd_windows_test.go
index f99c1b18f5..e4bc92503b 100644
--- a/shell/run_shell_cmd_windows_test.go
+++ b/shell/run_shell_cmd_windows_test.go
@@ -5,6 +5,7 @@ package shell
 
 import (
 	goerrors "errors"
+	"fmt"
 	"os"
 	"os/exec"
 	"strconv"
@@ -76,3 +77,28 @@ func TestNewSignalsForwarderWaitWindows(t *testing.T) {
 	// assert.WithinDuration(t, start.Add(time.Duration(expectedWait)*time.Second), time.Now(), time.Second,
 	// 	"Expected to wait 5 (+/-1) seconds after SIGINT")
 }
+
+func TestRunShellCommandWithOutputInterrupt(t *testing.T) {
+	t.Parallel()
+
+	terragruntOptions, err := options.NewTerragruntOptionsForTest("")
+	assert.Nil(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err)
+
+	errCh := make(chan error)
+	expectedWait := 5
+
+	go func() {
+		_, err := RunShellCommandWithOutput(terragruntOptions, "", false, false, "../testdata/test_sigint_wait.bat", strconv.Itoa(expectedWait))
+		errCh <- err
+	}()
+
+	time.AfterFunc(3*time.Second, func() {
+		process, err := os.FindProcess(os.Getpid())
+		assert.NoError(t, err)
+
+		process.Signal(os.Kill)
+	})
+
+	expectedErr := fmt.Sprintf("exit status %d", expectedWait)
+	assert.EqualError(t, <-errCh, expectedErr)
+}