internal/gocommand: kill process group on cancel

When a Go command was cancelled, invoke.go sent SIGINT
and SIGKILL only to the top-level go process. Subprocesses spawned
by the go command (such as cgo and C compilers during 'go list'
or 'go build') did not receive the signal and continued running
as orphans in the background, consuming CPU cores.

On Unix, configure the command to run as the leader of its own process
group, and send cancellation signals (SIGINT, SIGKILL)
to the entire process group (using -pid) so that descendant processes are
terminated promptly upon cancellation.

(It might be possible to do this for Windows, in its own CL.)

For golang/go#81408.

Change-Id: I4484ea520f20312ac9bba437ce613ffb4a10e398
Reviewed-on: https://go-review.googlesource.com/c/tools/+/830824
Reviewed-by: Madeline Kalil <mkalil@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gocommand/invoke.go b/internal/gocommand/invoke.go
index 5872120..45ccf27 100644
--- a/internal/gocommand/invoke.go
+++ b/internal/gocommand/invoke.go
@@ -246,6 +246,7 @@
 		goArgs = append(goArgs, i.Args...)
 	}
 	cmd := exec.Command("go", goArgs...)
+	setProcessGroup(cmd)
 	cmd.Stdout = stdout
 	cmd.Stderr = stderr
 
@@ -402,7 +403,7 @@
 	}
 
 	// Cancelled. Interrupt and see if it ends voluntarily.
-	if err := cmd.Process.Signal(os.Interrupt); err == nil {
+	if err := interruptProcess(cmd); err == nil {
 		// (We used to wait only 1s but this proved
 		// fragile on loaded builder machines.)
 		timer := time.NewTimer(5 * time.Second)
@@ -415,7 +416,7 @@
 	}
 
 	// Didn't shut down in response to interrupt. Kill it hard.
-	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) && debug {
+	if err := killProcess(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) && debug {
 		log.Printf("error killing the Go command: %v", err)
 	}
 
diff --git a/internal/gocommand/invoke_notunix.go b/internal/gocommand/invoke_notunix.go
index 469c648..a156a8f 100644
--- a/internal/gocommand/invoke_notunix.go
+++ b/internal/gocommand/invoke_notunix.go
@@ -6,8 +6,40 @@
 
 package gocommand
 
-import "os"
+import (
+	"errors"
+	"os"
+	"os/exec"
+)
 
 // sigStuckProcess is the signal to send to kill a hanging subprocess.
 // On Unix we send SIGQUIT, but on non-Unix we only have os.Kill.
 var sigStuckProcess = os.Kill
+
+// setProcessGroup is a no-op on non-Unix platforms.
+//
+// As a consequence of not using process groups (or Windows job objects),
+// signals sent to cmd.Process will not propagate to descendant processes
+// spawned by the Go command (such as cgo or C compilers). If the command
+// is cancelled, those subprocesses may continue running in the background
+// until they finish or fail due to broken pipes.
+// TODO(pjw): consider adding Windows job objects.
+func setProcessGroup(cmd *exec.Cmd) {
+}
+
+// interruptProcess sends an interrupt signal to the process. On Windows,
+// os.Interrupt is not supported and will return an error, causing the caller
+// to fall back to killProcess immediately.
+func interruptProcess(cmd *exec.Cmd) error {
+	return cmd.Process.Signal(os.Interrupt)
+}
+
+// killProcess terminates the process directly. Subprocesses spawned by the
+// Go command will not be killed directly.
+func killProcess(cmd *exec.Cmd) error {
+	err := cmd.Process.Kill()
+	if errors.Is(err, os.ErrProcessDone) {
+		return nil
+	}
+	return err
+}
diff --git a/internal/gocommand/invoke_unix.go b/internal/gocommand/invoke_unix.go
index 169d37c..be60ebb 100644
--- a/internal/gocommand/invoke_unix.go
+++ b/internal/gocommand/invoke_unix.go
@@ -6,8 +6,48 @@
 
 package gocommand
 
-import "syscall"
+import (
+	"errors"
+	"os/exec"
+	"syscall"
+)
 
-// Sigstuckprocess is the signal to send to kill a hanging subprocess.
+// sigStuckProcess is the signal to send to kill a hanging subprocess.
 // Send SIGQUIT to get a stack trace.
 var sigStuckProcess = syscall.SIGQUIT
+
+// setProcessGroup sets Setpgid so that the child process runs as the leader of
+// its own process group. Any subprocesses spawned by the Go command (such as
+// cgo, compilers, or assemblers) will inherit this process group, allowing
+// cancellation signals to reach all descendant processes rather than orphaning
+// them (see golang/go#81408).
+func setProcessGroup(cmd *exec.Cmd) {
+	if cmd.SysProcAttr == nil {
+		cmd.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	cmd.SysProcAttr.Setpgid = true
+}
+
+// interruptProcess sends SIGINT to the entire process group (-pid) so that both
+// the Go command and any subprocesses (such as C compilers) get
+// the interrupt signal and terminate promptly.
+//
+// Unlike cmd.Process.Signal, syscall.Kill(-pid, ...) does not synchronize with
+// cmd.Process.Wait via os.Process's internal lock. PGID reuse is not a
+// problem, as the kernel reserves the PGID as long as any member
+// of the process group is alive, and runCmdContext only signals the
+// process group while cmd.Wait is still blocked on the process or its open pipes.
+func interruptProcess(cmd *exec.Cmd) error {
+	return syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
+}
+
+// killProcess sends SIGKILL to the entire process group (-pid) to forcefully
+// terminate any remaining descendant processes. It ignores ESRCH if the process
+// group has already terminated.
+func killProcess(cmd *exec.Cmd) error {
+	err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+	if errors.Is(err, syscall.ESRCH) {
+		return nil
+	}
+	return err
+}
diff --git a/internal/gocommand/invoke_unix_test.go b/internal/gocommand/invoke_unix_test.go
new file mode 100644
index 0000000..624b81e
--- /dev/null
+++ b/internal/gocommand/invoke_unix_test.go
@@ -0,0 +1,108 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix
+
+package gocommand_test
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"syscall"
+	"testing"
+	"time"
+
+	"golang.org/x/tools/internal/gocommand"
+	"golang.org/x/tools/internal/testenv"
+)
+
+// TestCancel_ProcessGroup checks that cancelling a Go command terminates
+// subprocesses spawned in its process group (golang/go#81408).
+func TestCancel_ProcessGroup(t *testing.T) {
+	testenv.NeedsTool(t, "go")
+
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module cancel.test\n\ngo 1.20\n"), 0666); err != nil {
+		t.Fatal(err)
+	}
+
+	pidFile := filepath.Join(dir, "child.pid")
+	testSrc := fmt.Sprintf(`package main
+
+import (
+	"fmt"
+	"os"
+	"testing"
+	"time"
+)
+
+func TestSleep(t *testing.T) {
+	if err := os.WriteFile(%q, []byte(fmt.Sprint(os.Getpid())), 0666); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(30 * time.Second)
+}
+`, pidFile)
+	if err := os.WriteFile(filepath.Join(dir, "sleep_test.go"), []byte(testSrc), 0666); err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	var runner gocommand.Runner
+	errCh := make(chan error, 1)
+	go func() {
+		_, err := runner.Run(ctx, gocommand.Invocation{
+			Verb:       "test",
+			Args:       []string{"-v", "."},
+			WorkingDir: dir,
+		})
+		errCh <- err
+	}()
+
+	// Wait for the test process to start and write its PID.
+	var childPID int
+	for delay := time.Millisecond; delay < 10*time.Second; delay *= 2 {
+		data, err := os.ReadFile(pidFile)
+		if err == nil && len(data) > 0 {
+			fmt.Sscanf(string(data), "%d", &childPID)
+			if childPID > 0 {
+				break
+			}
+		}
+		time.Sleep(delay)
+	}
+	if childPID == 0 {
+		t.Fatal("child test process did not write PID")
+	}
+
+	cancel()
+
+	select {
+	case err := <-errCh:
+		if err == nil {
+			t.Fatal("command succeeded unexpectedly, wanted cancellation error")
+		}
+	case <-time.After(10 * time.Second):
+		t.Fatal("timed out waiting for command to cancel")
+	}
+
+	// Verify that the child test process in the process group was killed.
+	dead := false
+	for delay := time.Millisecond; delay < 5*time.Second; delay *= 2 {
+		err := syscall.Kill(childPID, 0)
+		if errors.Is(err, syscall.ESRCH) {
+			dead = true
+			break
+		}
+		time.Sleep(delay)
+	}
+	if !dead {
+		t.Errorf("child process %d still alive after cancellation", childPID)
+	}
+}