diff --git a/main.go b/main.go index 3d835cbcb..9577dc5e9 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,26 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - if err := command.Main(ctx, os.Args); err != nil { + // intCh receives only SIGINT so we know whether the termination was + // caused by Ctrl-C (as opposed to SIGTERM or a command error). It + // must be registered *before* command.Main is called so the signal is + // captured even if the command returns very quickly. + intCh := make(chan os.Signal, 1) + signal.Notify(intCh, os.Interrupt) + defer signal.Stop(intCh) + + err := command.Main(ctx, os.Args) + + // Check non-blocking: if the channel already has a value, SIGINT arrived + // while the command was running. + select { + case <-intCh: + // SIGINT was the cause; exit 130 per POSIX convention (128 + 2). + os.Exit(130) + default: + } + + if err != nil { os.Exit(1) } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 000000000..e8f4cd198 --- /dev/null +++ b/main_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "os/exec" + "syscall" + "testing" + "time" +) + +// TestSIGINTExitCode verifies that sending SIGINT to s5cmd results in +// exit code 130 (the POSIX standard: 128 + signal number 2). +// +// The test builds the binary, starts it with a long-running command, +// sends SIGINT after a short delay, and checks the exit code. +func TestSIGINTExitCode(t *testing.T) { + if testing.Short() { + t.Skip("skipping subprocess test in short mode") + } + + // Build the binary into a temp dir. + tmpDir := t.TempDir() + bin := tmpDir + "/s5cmd" + if err := exec.Command("go", "build", "-o", bin, ".").Run(); err != nil { + t.Fatalf("failed to build s5cmd: %v", err) + } + + // Use --no-sign-request so credential lookup is skipped entirely, then + // point at a black-hole address. The binary will block on the TCP dial + // for as long as we need. Setting AWS_DEFAULT_REGION avoids the + // region-discovery HTTP call so the binary goes straight to dialling + // the black-hole endpoint. + cmd := exec.Command(bin, + "--no-sign-request", + "--endpoint-url", "http://10.255.255.1:19999", // routable but unreachable + "--retry-count", "10", + "ls", "s3://bucket/", + ) + cmd.Env = append(os.Environ(), "AWS_DEFAULT_REGION=us-east-1") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start s5cmd: %v", err) + } + + // Give the process time to start and block on the network dial. + // Use 3 s to remain reliable even under heavy test-suite parallelism. + time.Sleep(3 * time.Second) + + // Send SIGINT. + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("failed to send SIGINT: %v", err) + } + + err := cmd.Wait() + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("expected ExitError after SIGINT, got: %T %v", err, err) + } + + got := exitErr.ExitCode() + t.Logf("process state: %v", exitErr.ProcessState) + if got != 130 { + t.Errorf("expected exit code 130 after SIGINT, got %d", got) + } +}