diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index b6f805870..13d8d6900 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -213,6 +213,8 @@ type WorkspaceProjectConfig struct { Repos []WorkspaceProjectRepoConfig } +// WorkspaceProjectRepoConfig describes one registered child repo in a +// workspace project session. type WorkspaceProjectRepoConfig struct { Name string RelativePath string @@ -227,6 +229,8 @@ type WorkspaceProjectInfo struct { Worktrees []WorkspaceRepoInfo } +// WorkspaceRepoInfo describes one materialized repo worktree in a workspace +// project session. type WorkspaceRepoInfo struct { RepoName string RepoPath string diff --git a/backend/internal/processalive/process_unix.go b/backend/internal/processalive/process_unix.go index bf9349ad2..56221f888 100644 --- a/backend/internal/processalive/process_unix.go +++ b/backend/internal/processalive/process_unix.go @@ -5,16 +5,32 @@ package processalive import ( + "bytes" "errors" + "os/exec" + "strconv" "syscall" ) -// Alive reports whether pid exists. EPERM counts as alive: the process exists -// even if the current user cannot signal it. +// Alive reports whether pid maps to a running process. EPERM counts as alive: +// the process exists even if the current user cannot signal it. Zombies are +// treated as not alive because the executable has already exited; only its +// parent has not reaped the process table entry yet. func Alive(pid int) bool { if pid <= 0 { return false } err := syscall.Kill(pid, 0) - return err == nil || errors.Is(err, syscall.EPERM) + if err != nil && !errors.Is(err, syscall.EPERM) { + return false + } + return !isZombie(pid) +} + +func isZombie(pid int) bool { + out, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return false + } + return bytes.HasPrefix(bytes.TrimSpace(out), []byte("Z")) } diff --git a/backend/internal/processalive/process_unix_test.go b/backend/internal/processalive/process_unix_test.go new file mode 100644 index 000000000..9d5309327 --- /dev/null +++ b/backend/internal/processalive/process_unix_test.go @@ -0,0 +1,29 @@ +//go:build !windows + +package processalive + +import ( + "os/exec" + "testing" + "time" +) + +func TestAliveReportsZombieAsDead(t *testing.T) { + cmd := exec.Command("sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + defer func() { _ = cmd.Wait() }() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if isZombie(cmd.Process.Pid) { + if Alive(cmd.Process.Pid) { + t.Fatal("Alive returned true for zombie process") + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not become a zombie before timeout") +}