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
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ bundle:
version: "0.8.0-dev"
release_sequence: 0
channel: "development"
source_tree_digest: "sha256:d76906627f908b796164bf3070579800f5087462335823f6524ff918862e991a"
digest: "sha256:71793f34bbb38088800964c34a2c2a0f0ba02a0b725620db9d370c81ff4a9496"
source_tree_digest: "sha256:f650570277313a15cfc92ea1a12cd6f5c9b8af7ea410cffd5e8931ca8d01c70c"
digest: "sha256:7c44e6973edfd5c73f73b9e6d414b263ceee36faaa17072d82bb2bc40b6b6beb"

projection:
input_digest: "sha256:5b4f5697a87f13d38bedc7ae5b37a36de65b08c2cd4986358f526f90cb005e04"
output_digest: "sha256:186592ce7518765ffa6c604a16740c827ad8f6ce1f0eb6aa4f6822dafe028321"
input_digest: "sha256:9f629a827361728b8ccb873792881b41b4a396ff9a0f73f25d97e47c26e2a3fc"
output_digest: "sha256:eded8fc365dbce57f899de267242c2ee796552f5cb509046af4dbf9eb7dbf701"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:28e93787aeee67af6e43b237224ec5444ea01dcf7e87079cf910d7acc2de225e"
digest: "sha256:4b482bb6bc7b3eef9ba5e2a2bf09c955ef6d6213fea8485c8586dbc79328511f"
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GENERATED FILE - DO NOT EDIT DIRECTLY
# generator: gds
# bundle: 0.8.0-dev
# source-tree-digest: sha256:d76906627f908b796164bf3070579800f5087462335823f6524ff918862e991a
# input-digest: sha256:5b4f5697a87f13d38bedc7ae5b37a36de65b08c2cd4986358f526f90cb005e04
# source-tree-digest: sha256:f650570277313a15cfc92ea1a12cd6f5c9b8af7ea410cffd5e8931ca8d01c70c
# input-digest: sha256:9f629a827361728b8ccb873792881b41b4a396ff9a0f73f25d97e47c26e2a3fc
# output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c
# edit-source:
# - .gds/repository.yaml
Expand Down
16 changes: 16 additions & 0 deletions core/app/module_process_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build darwin

package app

import (
"errors"
"syscall"
)

func moduleProcessGroupRunning(group int) (bool, error) {
err := syscall.Kill(-group, 0)
if errors.Is(err, syscall.ESRCH) {
return false, nil
}
return true, err
}
55 changes: 55 additions & 0 deletions core/app/module_process_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//go:build linux

package app

import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)

func moduleProcessGroupRunning(group int) (bool, error) {
if err := syscall.Kill(-group, 0); errors.Is(err, syscall.ESRCH) {
return false, nil
} else if err != nil {
return true, err
}
// Killed orphans may await init's reaping. Zombies cannot execute or write
// the checkout; do not mistake their remaining group membership for work.
entries, err := os.ReadDir("/proc")
if err != nil {
return true, err
}
for _, entry := range entries {
if _, err := strconv.Atoi(entry.Name()); err != nil {
continue
}
raw, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "stat"))
if os.IsNotExist(err) {
continue
}
if err != nil {
return true, fmt.Errorf("inspect process group: %w", err)
}
end := strings.LastIndexByte(string(raw), ')')
if end < 0 {
return true, errors.New("process status is incomplete")
}
fields := strings.Fields(string(raw)[end+1:])
if len(fields) < 3 {
return true, errors.New("process status is incomplete")
}
pgid, err := strconv.Atoi(fields[2])
if err != nil {
return true, err
}
if pgid == group && fields[0] != "Z" && fields[0] != "X" {
return true, nil
}
}
return false, nil
}
12 changes: 12 additions & 0 deletions core/app/module_process_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !darwin && !linux

package app

import (
"errors"
"os/exec"
)

func configureModuleProcess(_ *exec.Cmd) (func() (bool, error), error) {
return nil, errors.New("module command process ownership is supported only on Linux and macOS")
}
77 changes: 77 additions & 0 deletions core/app/module_process_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//go:build darwin || linux

package app

import (
"errors"
"fmt"
"os"
"os/exec"
"sync"
"syscall"
"time"
)

const moduleTerminationGrace = 250 * time.Millisecond
const moduleTerminationWait = 2 * time.Second

// Own a new group, never the caller's group. Cancel and normal-exit cleanup
// share one synchronous operation, so no delayed signal goroutine outlives the
// command or its verification workspace.
func configureModuleProcess(command *exec.Cmd) (func() (bool, error), error) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
var once sync.Once
var present bool
var stopErr error
stop := func() (bool, error) {
once.Do(func() {
if command.Process == nil {
return
}
pid := command.Process.Pid
if pid <= 1 || pid == syscall.Getpgrp() {
stopErr = errors.New("refusing to signal an unowned process group")
return
}
err := syscall.Kill(-pid, syscall.SIGTERM)
if errors.Is(err, syscall.ESRCH) {
return
}
present = true
if err != nil {
stopErr = fmt.Errorf("terminate module process group: %w", err)
return
}
time.Sleep(moduleTerminationGrace)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
stopErr = fmt.Errorf("kill module process group: %w", err)
return
}
deadline := time.Now().Add(moduleTerminationWait)
for {
running, err := moduleProcessGroupRunning(pid)
if err != nil {
stopErr = err
return
}
if !running {
return
}
if time.Now().After(deadline) {
stopErr = errors.New("module process group did not stop within cleanup deadline")
return
}
time.Sleep(10 * time.Millisecond)
}
})
return present, stopErr
}
command.Cancel = func() error {
present, err := stop()
if !present && err == nil {
return os.ErrProcessDone
}
return err
}
return stop, nil
}
110 changes: 110 additions & 0 deletions core/app/module_process_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//go:build darwin || linux

package app

import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)

// Run the actual shell/pipe/descendant path. A bounded Wait on the shell alone
// cannot prove that its children stopped touching the verification checkout.
func TestDeclaredTimeoutStopsStubbornDescendants(t *testing.T) {
dir := t.TempDir()
worker := "trap '' TERM\necho $$ > child.pid\nwhile :; do echo tick >> heartbeat; sleep 0.03; done\n"
if err := os.WriteFile(filepath.Join(dir, "worker.sh"), []byte(worker), 0o600); err != nil {
t.Fatal(err)
}
report := runDeclaredCommand(context.Background(), dir, "bash worker.sh & wait", 350*time.Millisecond)
pid := readOwnedTestChild(t, dir)
defer stopOwnedTestChild(pid)
if report.Status != "timeout" {
t.Fatalf("report=%#v", report)
}
assertTestChildStopped(t, pid)
before, err := os.ReadFile(filepath.Join(dir, "heartbeat"))
if err != nil {
t.Fatal(err)
}
time.Sleep(120 * time.Millisecond)
after, err := os.ReadFile(filepath.Join(dir, "heartbeat"))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("descendant wrote after command completion")
}
}

func TestDeclaredCancellationStopsPipelineChildren(t *testing.T) {
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan CommandReport, 1)
go func() {
done <- runDeclaredCommand(ctx, dir, "sleep 20 & echo $! > child.pid; wait | cat", 30*time.Second)
}()
pid := readOwnedTestChild(t, dir)
defer stopOwnedTestChild(pid)
cancel()
select {
case report := <-done:
if report.Status == "passed" {
t.Fatalf("cancellation passed: %#v", report)
}
case <-time.After(6 * time.Second):
t.Fatal("cancellation did not settle")
}
assertTestChildStopped(t, pid)
}

func TestDeclaredSuccessCannotLeaveBackgroundWriter(t *testing.T) {
dir := t.TempDir()
report := runDeclaredCommand(context.Background(), dir,
"sleep 20 </dev/null >/dev/null 2>&1 & echo $! > child.pid", time.Second)
pid := readOwnedTestChild(t, dir)
defer stopOwnedTestChild(pid)
if report.Status == "passed" {
t.Error("unjoined background process was reported as completed work")
}
assertTestChildStopped(t, pid)
}

func readOwnedTestChild(t *testing.T, dir string) int {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
b, err := os.ReadFile(filepath.Join(dir, "child.pid"))
if err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err == nil && pid > 1 {
return pid
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("test child did not start")
return 0
}

func stopOwnedTestChild(pid int) {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}

func assertTestChildStopped(t *testing.T, pid int) {
t.Helper()
b, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
// An unreaped zombie has already stopped executing; reaping belongs to its
// parent/init. It cannot continue writing into the verification workspace.
if err == nil && strings.TrimSpace(string(b)) != "" && !strings.HasPrefix(strings.TrimSpace(string(b)), "Z") {
t.Fatalf("child %d still executes after command returned: %s", pid, b)
}
}
Loading
Loading