Skip to content
Open
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
15 changes: 9 additions & 6 deletions internal/graph/schema.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions internal/graph/schema.resolvers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (
"time"

"github.com/hmans/beans/internal/agent"
"github.com/hmans/beans/internal/terminal"
"github.com/hmans/beans/internal/testutil"
"github.com/hmans/beans/internal/worktree"
"github.com/hmans/beans/pkg/beangraph"
"github.com/hmans/beans/pkg/beangraph/model"
"github.com/hmans/beans/pkg/bean"
Expand Down Expand Up @@ -3252,3 +3255,71 @@ func TestListFiles(t *testing.T) {
})
}

func TestRemoveWorktreeCleansUpSessions(t *testing.T) {
repoDir, beansDir, wtRoot := testutil.InitTestRepo(t)
wtMgr := worktree.NewManager(repoDir, wtRoot, "main", "")
termMgr := terminal.NewManager(nil)
defer termMgr.Shutdown()
agentMgr := agent.NewManager("", nil)
defer agentMgr.Shutdown()

cfg := config.Default()
core := beancore.New(beansDir, cfg)
if err := core.Load(); err != nil {
t.Fatalf("core.Load: %v", err)
}

resolver := &Resolver{
CoreResolver: &beangraph.CoreResolver{Core: core},
WorktreeMgr: wtMgr,
TerminalMgr: termMgr,
AgentMgr: agentMgr,
}

// Create a worktree
wt, err := wtMgr.Create("test-wt")
if err != nil {
t.Fatalf("Create worktree: %v", err)
}

// Create terminal sessions and an agent session for this worktree
if _, err := termMgr.Create(wt.ID, os.TempDir(), 80, 24); err != nil {
t.Fatalf("Create terminal session: %v", err)
}
if _, err := termMgr.Create(wt.ID+RunSessionSuffix, os.TempDir(), 80, 24); err != nil {
t.Fatalf("Create run session: %v", err)
}
agentMgr.AddInfoMessage(wt.ID, "test")
agentMgr.GetSession(wt.ID).Status = agent.StatusRunning

// Verify all sessions exist
if termMgr.Get(wt.ID) == nil {
t.Fatal("terminal session should exist before removal")
}
if termMgr.Get(wt.ID+RunSessionSuffix) == nil {
t.Fatal("run session should exist before removal")
}
if agentMgr.GetSession(wt.ID) == nil {
t.Fatal("agent session should exist before removal")
}

// Remove the worktree
mr := resolver.Mutation()
if _, err := mr.RemoveWorktree(context.Background(), wt.ID); err != nil {
t.Fatalf("RemoveWorktree: %v", err)
}

// All sessions should be closed/stopped
if termMgr.Get(wt.ID) != nil {
t.Error("terminal session should be closed after worktree removal")
}
if termMgr.Get(wt.ID+RunSessionSuffix) != nil {
t.Error("run session should be closed after worktree removal")
}
if s := agentMgr.GetSession(wt.ID); s == nil {
t.Error("agent session should still exist after worktree removal")
} else if s.Status != agent.StatusIdle {
t.Errorf("agent session status = %q, want %q", s.Status, agent.StatusIdle)
}
}

24 changes: 24 additions & 0 deletions internal/terminal/process_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build !windows

package terminal

import (
"syscall"
"time"
)

const processGroupGrace = 3 * time.Second

// killProcessGroup sends SIGTERM to the process group, then escalates to
// SIGKILL after the grace period. go-pty sets Setsid on every spawned command,
// so the PID is always the process group leader.
func killProcessGroup(pid int, done <-chan struct{}) {
_ = syscall.Kill(-pid, syscall.SIGTERM)

select {
case <-done:
return
case <-time.After(processGroupGrace):
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}
83 changes: 83 additions & 0 deletions internal/terminal/process_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build !windows

package terminal

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

func TestCloseKillsProcessGroup(t *testing.T) {
mgr := NewManager(nil)
defer mgr.Shutdown()

pidFile := filepath.Join(t.TempDir(), "child.pid")

command := fmt.Sprintf(`sh -c 'echo $$ > %s; sleep 300' & wait`, pidFile)
_, err := mgr.CreateWithCommand("test-pgkill", os.TempDir(), 80, 24, command)
if err != nil {
t.Fatalf("CreateWithCommand failed: %v", err)
}

var childPID int
deadline := time.After(5 * time.Second)
for {
data, readErr := os.ReadFile(pidFile)
if readErr == nil {
trimmed := strings.TrimSpace(string(data))
if trimmed != "" {
childPID, err = strconv.Atoi(trimmed)
if err == nil {
break
}
}
}
select {
case <-deadline:
t.Fatal("timed out waiting for child PID file")
default:
time.Sleep(50 * time.Millisecond)
}
}

if err := syscall.Kill(childPID, 0); err != nil {
t.Fatalf("child process %d not alive before close: %v", childPID, err)
}

mgr.Close("test-pgkill")

time.Sleep(500 * time.Millisecond)

if err := syscall.Kill(childPID, 0); err == nil {
t.Fatalf("child process %d still alive after session close", childPID)
}
}

func TestCloseProcessGroupGracefulShutdown(t *testing.T) {
mgr := NewManager(nil)
defer mgr.Shutdown()

// trap SIGTERM so the process exits cleanly on SIGTERM (not SIGKILL)
sess, err := mgr.CreateWithCommand("test-pg-graceful", os.TempDir(), 80, 24,
`trap 'exit 0' TERM; sleep 300`)
if err != nil {
t.Fatalf("CreateWithCommand failed: %v", err)
}

// Give the shell time to set up the trap
time.Sleep(200 * time.Millisecond)

mgr.Close("test-pg-graceful")

select {
case <-sess.Done():
case <-time.After(5 * time.Second):
t.Fatal("session did not exit after SIGTERM")
}
}
11 changes: 11 additions & 0 deletions internal/terminal/process_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build windows

package terminal

import "os"

func killProcessGroup(pid int, _ <-chan struct{}) {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}
5 changes: 3 additions & 2 deletions internal/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,13 @@ func (s *Session) readLoop() {
}
}

// Close kills the process and closes the PTY.
// Close kills the process group and closes the PTY. go-pty sets Setsid on
// every spawned command, so the PID is always the process group leader.
func (s *Session) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
killProcessGroup(s.cmd.Process.Pid, s.done)
}
_ = s.pty.Close()
_ = s.cmd.Wait()
Expand Down
37 changes: 37 additions & 0 deletions internal/testutil/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package testutil

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

// InitTestRepo creates a temporary git repo with an initial commit,
// a .beans directory inside it, and a separate worktree root directory.
func InitTestRepo(t *testing.T) (repoDir, beansDir, worktreeRoot string) {
t.Helper()
dir := t.TempDir()

for _, args := range [][]string{
{"git", "init", "-b", "main"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
{"git", "commit", "--allow-empty", "-m", "initial"},
} {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v failed: %s: %v", args, out, err)
}
}

bd := filepath.Join(dir, ".beans")
if err := os.MkdirAll(bd, 0755); err != nil {
t.Fatalf("MkdirAll .beans: %v", err)
}

wtRoot := t.TempDir()

return dir, bd, wtRoot
}
Loading
Loading