Skip to content

Commit 162cccf

Browse files
fix: own module verification process groups through cleanup
Setpgid plus TERM/KILL with a bounded wait, preserve the checkout when cleanup is not proven, and skip later lanes so a leftover writer cannot be reported as a passing verification. Signed-off-by: rldyourmnd <danil@nddev.it.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a152930 commit 162cccf

7 files changed

Lines changed: 458 additions & 8 deletions

‎core/app/module_process_darwin.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build darwin
2+
3+
package app
4+
5+
import (
6+
"errors"
7+
"syscall"
8+
)
9+
10+
func moduleProcessGroupRunning(group int) (bool, error) {
11+
err := syscall.Kill(-group, 0)
12+
if errors.Is(err, syscall.ESRCH) {
13+
return false, nil
14+
}
15+
return true, err
16+
}

‎core/app/module_process_linux.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//go:build linux
2+
3+
package app
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
)
14+
15+
func moduleProcessGroupRunning(group int) (bool, error) {
16+
if err := syscall.Kill(-group, 0); errors.Is(err, syscall.ESRCH) {
17+
return false, nil
18+
} else if err != nil {
19+
return true, err
20+
}
21+
// Killed orphans may await init's reaping. Zombies cannot execute or write
22+
// the checkout; do not mistake their remaining group membership for work.
23+
entries, err := os.ReadDir("/proc")
24+
if err != nil {
25+
return true, err
26+
}
27+
for _, entry := range entries {
28+
if _, err := strconv.Atoi(entry.Name()); err != nil {
29+
continue
30+
}
31+
raw, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "stat"))
32+
if os.IsNotExist(err) {
33+
continue
34+
}
35+
if err != nil {
36+
return true, fmt.Errorf("inspect process group: %w", err)
37+
}
38+
end := strings.LastIndexByte(string(raw), ')')
39+
if end < 0 {
40+
return true, errors.New("process status is incomplete")
41+
}
42+
fields := strings.Fields(string(raw)[end+1:])
43+
if len(fields) < 3 {
44+
return true, errors.New("process status is incomplete")
45+
}
46+
pgid, err := strconv.Atoi(fields[2])
47+
if err != nil {
48+
return true, err
49+
}
50+
if pgid == group && fields[0] != "Z" && fields[0] != "X" {
51+
return true, nil
52+
}
53+
}
54+
return false, nil
55+
}

‎core/app/module_process_other.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !darwin && !linux
2+
3+
package app
4+
5+
import (
6+
"errors"
7+
"os/exec"
8+
)
9+
10+
func configureModuleProcess(_ *exec.Cmd) (func() (bool, error), error) {
11+
return nil, errors.New("module command process ownership is supported only on Linux and macOS")
12+
}

‎core/app/module_process_unix.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//go:build darwin || linux
2+
3+
package app
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"sync"
11+
"syscall"
12+
"time"
13+
)
14+
15+
const moduleTerminationGrace = 250 * time.Millisecond
16+
const moduleTerminationWait = 2 * time.Second
17+
18+
// Own a new group, never the caller's group. Cancel and normal-exit cleanup
19+
// share one synchronous operation, so no delayed signal goroutine outlives the
20+
// command or its verification workspace.
21+
func configureModuleProcess(command *exec.Cmd) (func() (bool, error), error) {
22+
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
23+
var once sync.Once
24+
var present bool
25+
var stopErr error
26+
stop := func() (bool, error) {
27+
once.Do(func() {
28+
if command.Process == nil {
29+
return
30+
}
31+
pid := command.Process.Pid
32+
if pid <= 1 || pid == syscall.Getpgrp() {
33+
stopErr = errors.New("refusing to signal an unowned process group")
34+
return
35+
}
36+
err := syscall.Kill(-pid, syscall.SIGTERM)
37+
if errors.Is(err, syscall.ESRCH) {
38+
return
39+
}
40+
present = true
41+
if err != nil {
42+
stopErr = fmt.Errorf("terminate module process group: %w", err)
43+
return
44+
}
45+
time.Sleep(moduleTerminationGrace)
46+
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
47+
stopErr = fmt.Errorf("kill module process group: %w", err)
48+
return
49+
}
50+
deadline := time.Now().Add(moduleTerminationWait)
51+
for {
52+
running, err := moduleProcessGroupRunning(pid)
53+
if err != nil {
54+
stopErr = err
55+
return
56+
}
57+
if !running {
58+
return
59+
}
60+
if time.Now().After(deadline) {
61+
stopErr = errors.New("module process group did not stop within cleanup deadline")
62+
return
63+
}
64+
time.Sleep(10 * time.Millisecond)
65+
}
66+
})
67+
return present, stopErr
68+
}
69+
command.Cancel = func() error {
70+
present, err := stop()
71+
if !present && err == nil {
72+
return os.ErrProcessDone
73+
}
74+
return err
75+
}
76+
return stop, nil
77+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//go:build darwin || linux
2+
3+
package app
4+
5+
import (
6+
"context"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"testing"
13+
"time"
14+
)
15+
16+
// Run the actual shell/pipe/descendant path. A bounded Wait on the shell alone
17+
// cannot prove that its children stopped touching the verification checkout.
18+
func TestDeclaredTimeoutStopsStubbornDescendants(t *testing.T) {
19+
dir := t.TempDir()
20+
worker := "trap '' TERM\necho $$ > child.pid\nwhile :; do echo tick >> heartbeat; sleep 0.03; done\n"
21+
if err := os.WriteFile(filepath.Join(dir, "worker.sh"), []byte(worker), 0o600); err != nil {
22+
t.Fatal(err)
23+
}
24+
report := runDeclaredCommand(context.Background(), dir, "bash worker.sh & wait", 350*time.Millisecond)
25+
pid := readOwnedTestChild(t, dir)
26+
defer stopOwnedTestChild(pid)
27+
if report.Status != "timeout" {
28+
t.Fatalf("report=%#v", report)
29+
}
30+
assertTestChildStopped(t, pid)
31+
before, err := os.ReadFile(filepath.Join(dir, "heartbeat"))
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
time.Sleep(120 * time.Millisecond)
36+
after, err := os.ReadFile(filepath.Join(dir, "heartbeat"))
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
if string(before) != string(after) {
41+
t.Fatal("descendant wrote after command completion")
42+
}
43+
}
44+
45+
func TestDeclaredCancellationStopsPipelineChildren(t *testing.T) {
46+
dir := t.TempDir()
47+
ctx, cancel := context.WithCancel(context.Background())
48+
defer cancel()
49+
done := make(chan CommandReport, 1)
50+
go func() {
51+
done <- runDeclaredCommand(ctx, dir, "sleep 20 & echo $! > child.pid; wait | cat", 30*time.Second)
52+
}()
53+
pid := readOwnedTestChild(t, dir)
54+
defer stopOwnedTestChild(pid)
55+
cancel()
56+
select {
57+
case report := <-done:
58+
if report.Status == "passed" {
59+
t.Fatalf("cancellation passed: %#v", report)
60+
}
61+
case <-time.After(6 * time.Second):
62+
t.Fatal("cancellation did not settle")
63+
}
64+
assertTestChildStopped(t, pid)
65+
}
66+
67+
func TestDeclaredSuccessCannotLeaveBackgroundWriter(t *testing.T) {
68+
dir := t.TempDir()
69+
report := runDeclaredCommand(context.Background(), dir,
70+
"sleep 20 </dev/null >/dev/null 2>&1 & echo $! > child.pid", time.Second)
71+
pid := readOwnedTestChild(t, dir)
72+
defer stopOwnedTestChild(pid)
73+
if report.Status == "passed" {
74+
t.Error("unjoined background process was reported as completed work")
75+
}
76+
assertTestChildStopped(t, pid)
77+
}
78+
79+
func readOwnedTestChild(t *testing.T, dir string) int {
80+
t.Helper()
81+
deadline := time.Now().Add(3 * time.Second)
82+
for time.Now().Before(deadline) {
83+
b, err := os.ReadFile(filepath.Join(dir, "child.pid"))
84+
if err == nil {
85+
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
86+
if err == nil && pid > 1 {
87+
return pid
88+
}
89+
}
90+
time.Sleep(10 * time.Millisecond)
91+
}
92+
t.Fatal("test child did not start")
93+
return 0
94+
}
95+
96+
func stopOwnedTestChild(pid int) {
97+
if p, err := os.FindProcess(pid); err == nil {
98+
_ = p.Kill()
99+
}
100+
}
101+
102+
func assertTestChildStopped(t *testing.T, pid int) {
103+
t.Helper()
104+
b, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
105+
// An unreaped zombie has already stopped executing; reaping belongs to its
106+
// parent/init. It cannot continue writing into the verification workspace.
107+
if err == nil && strings.TrimSpace(string(b)) != "" && !strings.HasPrefix(strings.TrimSpace(string(b)), "Z") {
108+
t.Fatalf("child %d still executes after command returned: %s", pid, b)
109+
}
110+
}

‎core/app/module_verify.go‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ type CommandReport struct {
5151
// "No module named pytest" is not a broken module, and a reader must be able
5252
// to see that without rerunning anything.
5353
Diagnostic string `json:"diagnostic,omitempty"`
54+
// A failed cleanup must not be followed by workspace deletion or another lane.
55+
CleanupPending bool `json:"cleanup_pending,omitempty"`
5456
}
5557

5658
const defaultModuleCommandTimeout = 10 * time.Minute
@@ -176,13 +178,13 @@ func (services *Services) runModuleLanes(
176178
modulePath string,
177179
plan moduleworkflow.VerificationPlan,
178180
timeout time.Duration,
179-
) (ModuleVerification, []domain.Finding) {
180-
report := ModuleVerification{
181+
) (report ModuleVerification, findings []domain.Finding) {
182+
report = ModuleVerification{
181183
GitmodulesName: plan.GitmodulesName, Path: plan.Path,
182184
GitlinkOID: plan.GitlinkOID, RepositoryID: plan.RepositoryID,
183185
Lanes: []LaneReport{},
184186
}
185-
findings := []domain.Finding{}
187+
findings = []domain.Finding{}
186188

187189
workspace, err := os.MkdirTemp("", "gds-module-verify-")
188190
if err != nil {
@@ -192,8 +194,27 @@ func (services *Services) runModuleLanes(
192194
Evidence: map[string]any{"gitmodules_name": plan.GitmodulesName},
193195
})
194196
}
195-
defer os.RemoveAll(workspace)
196197
checkout := filepath.Join(workspace, "checkout")
198+
registered, preserve := false, false
199+
defer func() {
200+
if preserve {
201+
return
202+
}
203+
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
204+
defer cancel()
205+
var cleanupErr error
206+
if registered {
207+
cleanupErr = services.GitMutations.RemoveWorktree(cleanupCtx, modulePath, checkout)
208+
}
209+
if cleanupErr == nil {
210+
cleanupErr = os.RemoveAll(workspace)
211+
}
212+
if cleanupErr != nil {
213+
findings = append(findings, domain.Finding{Code: "GDS_MODULE_VERIFICATION_CLEANUP_NOT_PROVEN", Severity: domain.SeverityHigh,
214+
Message: "Verification workspace cleanup failed; retained state requires inspection.",
215+
Evidence: map[string]any{"workspace": workspace, "error": cleanupErr.Error()}})
216+
}
217+
}()
197218

198219
if err := services.GitMutations.AddDetachedWorktree(
199220
ctx, modulePath, checkout, plan.GitlinkOID,
@@ -206,16 +227,22 @@ func (services *Services) runModuleLanes(
206227
},
207228
})
208229
}
209-
defer func() {
210-
_ = services.GitMutations.RemoveWorktree(ctx, modulePath, checkout)
211-
}()
230+
registered = true
212231

213232
for _, lane := range plan.Lanes {
214233
laneReport := LaneReport{Lane: lane.Lane, Commands: []CommandReport{}}
215234
failed := false
216235
for _, declared := range lane.Commands {
217236
result := runDeclaredCommand(ctx, checkout, declared, timeout)
218237
laneReport.Commands = append(laneReport.Commands, result)
238+
if result.CleanupPending {
239+
preserve = true
240+
report.Lanes = append(report.Lanes, laneReport)
241+
findings = append(findings, domain.Finding{Code: "GDS_MODULE_VERIFICATION_CLEANUP_NOT_PROVEN", Severity: domain.SeverityHigh,
242+
Message: "Command descendants may still own the verification workspace; no later lane was started.",
243+
Evidence: map[string]any{"workspace": workspace, "command": declared, "diagnostic": result.Diagnostic}})
244+
return report, findings
245+
}
219246
if result.Status == "passed" {
220247
continue
221248
}
@@ -275,6 +302,10 @@ func runDeclaredCommand(
275302
command := exec.CommandContext(bounded, "bash", "-euo", "pipefail", "-c", declared)
276303
command.Dir = directory
277304
command.Stdin = nil
305+
stop, configureErr := configureModuleProcess(command)
306+
if configureErr != nil {
307+
return CommandReport{Command: declared, Status: "failed", ExitCode: -1, Diagnostic: configureErr.Error()}
308+
}
278309
// This selector belongs to the controller operation. Module commands prove
279310
// their own source checkout, and must not silently select its consumer's
280311
// estate. A declared command can still explicitly select an estate itself.
@@ -291,14 +322,23 @@ func runDeclaredCommand(
291322
command.Stdout = diagnostic
292323
command.Stderr = diagnostic
293324
err := command.Run()
325+
leftover, cleanupErr := stop()
326+
if err == nil && leftover {
327+
err = errors.New("declared command exited with unjoined descendants")
328+
}
329+
err = errors.Join(err, bounded.Err(), cleanupErr)
294330
report := CommandReport{
295331
Command: declared, Status: "passed",
296-
DurationMS: time.Since(started).Milliseconds(),
332+
DurationMS: time.Since(started).Milliseconds(),
333+
CleanupPending: cleanupErr != nil,
297334
}
298335
if err == nil {
299336
return report
300337
}
301338
report.Diagnostic = boundedDiagnostic(diagnostic.String())
339+
if cleanupErr != nil {
340+
report.Diagnostic = boundedDiagnostic(report.Diagnostic + "\n" + cleanupErr.Error())
341+
}
302342
if report.Diagnostic == "" {
303343
report.Diagnostic = boundedDiagnostic(err.Error())
304344
}

0 commit comments

Comments
 (0)