Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/codegraph/codegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func EnsureInit(ctx context.Context, bin, root string) error {
ctx, cancel := context.WithTimeout(ctx, initTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "init", root)
proc.SetProcessGroupKill(cmd) // own group so Cancel→KillTree reaps the tree off Windows (no-op on Windows)
cmd.Cancel = func() error { proc.KillTree(cmd); return nil }
cmd.WaitDelay = 3 * time.Second
proc.HideWindow(cmd)
Expand Down
34 changes: 28 additions & 6 deletions internal/proc/kill_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,42 @@

package proc

import "os/exec"
import (
"os/exec"
"syscall"
)

// KillTree terminates cmd's process; off Windows it kills the direct child only.
// KillTree kills cmd's whole process group. StartTracked (and SetProcessGroupKill
// for children started outside it) put the child in its own group via Setpgid, so
// the negative-pid signal reaches every descendant — a launcher whose sub-daemon
// survives the parent (e.g. codegraph's bundled node runtime) included, where a
// plain Process.Kill would only hit the direct child and orphan the grandchild.
func KillTree(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
_ = cmd.Process.Kill() // not a group leader (Setpgid wasn't set) — at least kill the child
}
}

// SetProcessGroupKill makes cmd start in its own process group so KillTree can
// reap its whole tree. Use it for children started outside StartTracked (e.g. a
// one-shot CombinedOutput). It is a no-op on Windows, where the Job Object that
// TrackTree/StartTracked assigns handles the tree instead.
func SetProcessGroupKill(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}

// StartTracked starts cmd. Off Windows there is no Job Object to track it in —
// the platform reaps the child directly — so it just starts and returns a 0
// handle, and KillTracked falls back to KillTree.
// StartTracked starts cmd in its own process group so KillTracked / KillTree can
// reap its whole tree. Off Windows the process group is the equivalent of the
// Windows Job Object; it returns a 0 handle, and KillTracked falls back to
// KillTree.
func StartTracked(cmd *exec.Cmd) (uintptr, error) {
SetProcessGroupKill(cmd)
return 0, cmd.Start()
}

Expand Down
42 changes: 42 additions & 0 deletions internal/proc/kill_other_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
package proc

import (
"bufio"
"errors"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
Expand Down Expand Up @@ -45,3 +50,40 @@ func TestKillTrackedTerminatesChild(t *testing.T) {
t.Fatal("cmd.Wait blocked after KillTracked")
}
}

// A launcher (sh) that backgrounds a grandchild (sleep) and stays alive: with
// the child in its own process group, KillTracked's negative-pid kill must reap
// the grandchild too, not just sh — the codegraph node-daemon leak this guards.
func TestKillTrackedReapsProcessGroupGrandchild(t *testing.T) {
cmd := exec.Command("sh", "-c", "sleep 60 & echo $!; wait")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("StdoutPipe: %v", err)
}
if _, err := StartTracked(cmd); err != nil {
t.Fatalf("StartTracked: %v", err)
}

line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read grandchild pid: %v", err)
}
gcPid, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || gcPid <= 0 {
t.Fatalf("bad grandchild pid %q: %v", line, err)
}
if syscall.Kill(gcPid, 0) != nil {
t.Fatalf("grandchild %d not alive before kill", gcPid)
}

KillTracked(cmd, 0)
_ = cmd.Wait()

deadline := time.Now().Add(5 * time.Second)
for !errors.Is(syscall.Kill(gcPid, 0), syscall.ESRCH) {
if time.Now().After(deadline) {
t.Fatalf("grandchild %d survived KillTracked — process group not reaped", gcPid)
}
time.Sleep(20 * time.Millisecond)
}
}
6 changes: 6 additions & 0 deletions internal/proc/kill_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import (
"golang.org/x/sys/windows"
)

// SetProcessGroupKill is a no-op on Windows: the Job Object that StartTracked
// assigns reaps the whole tree on close, so Setpgid (which doesn't exist here)
// is unnecessary. It exists so non-Windows callers can request group kill
// uniformly.
func SetProcessGroupKill(*exec.Cmd) {}

// KillTree terminates cmd and every descendant it spawned. Process.Kill only
// signals the direct child, so a launcher (cmd.exe → node.exe) leaves the
// grandchild alive holding the inherited stdout/stderr pipes — which makes
Expand Down
Loading