Skip to content

Commit 9e336b1

Browse files
rremerRoyce Remer
authored andcommitted
fix(process): introduce command.WithWaitDelay() defaulting to 10s to escalate SIGINT -> SIGKILL
Assisted-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Royce Remer <royceremer@gmail.com>
1 parent 5c8b79c commit 9e336b1

3 files changed

Lines changed: 87 additions & 0 deletions

File tree

modules/process/command.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import (
77
"bytes"
88
"context"
99
"os/exec"
10+
"time"
1011
)
1112

13+
// defaultWaitDelay is how long CommandContext waits after sending SIGTERM before escalating to SIGKILL.
14+
const defaultWaitDelay = 10 * time.Second
15+
1216
type Cmd struct {
1317
*exec.Cmd
1418

@@ -31,6 +35,11 @@ func (c *Cmd) WithDir(dir string) *Cmd {
3135
return c
3236
}
3337

38+
func (c *Cmd) WithWaitDelay(d time.Duration) *Cmd {
39+
c.Cmd.WaitDelay = d
40+
return c
41+
}
42+
3443
func (c *Cmd) OutputString() (string, string, error) {
3544
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
3645
c.Cmd.Stdout = stdout
@@ -46,6 +55,7 @@ func CommandContext(ctx context.Context, name string, arg ...string) *Cmd {
4655
c := &Cmd{Cmd: exec.CommandContext(ctx, name, arg...)}
4756
setSysProcAttribute(c.Cmd)
4857
c.Cmd.Cancel = c.onCancel
58+
c.Cmd.WaitDelay = defaultWaitDelay
4959
c.termGraceful = true
5060
return c
5161
}

modules/process/command_unix.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package process
88
import (
99
"os/exec"
1010
"syscall"
11+
"time"
1112

1213
"gitea.dev/modules/util"
1314
)
@@ -25,6 +26,13 @@ func (c *Cmd) onCancel() error {
2526
}
2627
}
2728
sig := util.Iif(c.termGraceful, syscall.SIGTERM, syscall.SIGKILL)
29+
if sig == syscall.SIGTERM && c.Cmd.WaitDelay > 0 {
30+
pgid, delay := c.Process.Pid, c.Cmd.WaitDelay
31+
go func() {
32+
time.Sleep(delay)
33+
_ = syscall.Kill(-pgid, syscall.SIGKILL)
34+
}()
35+
}
2836
// kill the whole process group
2937
return syscall.Kill(-c.Process.Pid, sig)
3038
}

modules/process/command_unix_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,72 @@ func TestCommandContextCancelKillProcessGroup(t *testing.T) {
6565
return grandchild.Signal(syscall.Signal(0)) != nil
6666
}, 5*time.Second, 10*time.Millisecond)
6767
}
68+
69+
func TestCommandContextWaitDelayOrphansGrandchild(t *testing.T) {
70+
ctx, cancel := context.WithCancel(t.Context())
71+
defer cancel()
72+
73+
r, w, err := os.Pipe()
74+
require.NoError(t, err)
75+
76+
// Shell ignores SIGTERM; grandchild inherits SIG_IGN and also survives the group SIGTERM.
77+
cmd := CommandContext(ctx, "sh", "-c", "trap '' TERM; sleep 600 & echo $!; wait").
78+
WithWaitDelay(1 * time.Second)
79+
cmd.Stdout = w
80+
require.NoError(t, cmd.Start())
81+
_ = w.Close()
82+
83+
var grandchildPID int
84+
t.Cleanup(func() {
85+
// Kill by process group — the PGID survives the group leader's death, so this reaches orphaned sleep.
86+
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
87+
_ = cmd.Wait()
88+
if grandchildPID > 0 {
89+
_ = syscall.Kill(grandchildPID, syscall.SIGKILL)
90+
}
91+
})
92+
93+
scanner := bufio.NewScanner(r)
94+
require.True(t, scanner.Scan(), "expected grandchild PID on stdout")
95+
grandchildPID, err = strconv.Atoi(strings.TrimSpace(scanner.Text()))
96+
require.NoError(t, err)
97+
_ = r.Close()
98+
99+
grandchild, err := os.FindProcess(grandchildPID)
100+
require.NoError(t, err)
101+
require.NoError(t, grandchild.Signal(syscall.Signal(0)), "grandchild should be alive before cancel")
102+
103+
cancel()
104+
_ = cmd.Wait() // blocks ~1s until WaitDelay SIGKILL kills the direct child (shell)
105+
106+
assert.Eventually(t, func() bool {
107+
return grandchild.Signal(syscall.Signal(0)) != nil
108+
}, 5*time.Second, 10*time.Millisecond, "grandchild was orphaned: WaitDelay SIGKILL did not reach the process group")
109+
}
110+
111+
func TestCommandContextSIGTERMIgnoredHangs(t *testing.T) {
112+
ctx, cancel := context.WithCancel(t.Context())
113+
defer cancel()
114+
115+
cmd := CommandContext(ctx, "sh", "-c", "trap '' TERM; sleep 600").WithWaitDelay(1 * time.Second)
116+
require.NoError(t, cmd.Start())
117+
118+
waitDone := make(chan struct{})
119+
go func() {
120+
_ = cmd.Wait()
121+
close(waitDone)
122+
}()
123+
124+
t.Cleanup(func() {
125+
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
126+
<-waitDone
127+
})
128+
129+
cancel()
130+
131+
select {
132+
case <-waitDone:
133+
case <-time.After(5 * time.Second):
134+
t.Fatal("process did not exit after context cancel: SIGTERM ignored and no SIGKILL escalation occurred")
135+
}
136+
}

0 commit comments

Comments
 (0)