Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
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
41 changes: 23 additions & 18 deletions cmd/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"github.com/agentuity/cli/internal/util"
"github.com/agentuity/go-common/env"
cstr "github.com/agentuity/go-common/string"
csys "github.com/agentuity/go-common/sys"
"github.com/agentuity/go-common/tui"
"github.com/bep/debounce"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -103,7 +102,10 @@ Examples:
}
defer websocketConn.Close()

projectServerCmd, err := dev.CreateRunProjectCmd(ctx, log, theproject, websocketConn, dir, orgId, port)
processCtx := context.Background()
var pid int

projectServerCmd, err := dev.CreateRunProjectCmd(processCtx, log, theproject, websocketConn, dir, orgId, port)
if err != nil {
errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit()
}
Expand Down Expand Up @@ -139,7 +141,7 @@ Examples:
build(false)
isDeliberateRestart = true
log.Debug("killing project server")
dev.KillProjectServer(projectServerCmd)
dev.KillProjectServer(log, projectServerCmd, pid)
log.Debug("killing project server done")
}

Expand All @@ -160,6 +162,8 @@ Examples:
errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start project: %s", err))).ShowErrorAndExit()
}

pid = projectServerCmd.Process.Pid

websocketConn.StartReadingMessages(ctx, log, port)
devUrl := websocketConn.WebURL(appUrl)

Expand All @@ -168,16 +172,16 @@ Examples:

go func() {
for {
log.Trace("waiting for project server to exit")
log.Trace("waiting for project server to exit (pid: %d)", pid)
if err := projectServerCmd.Wait(); err != nil {
log.Error("project server exited with error: %s", err)
log.Error("project server (pid: %d) exited with error: %s", pid, err)
}
if projectServerCmd.ProcessState != nil {
log.Debug("project server exited with code %d", projectServerCmd.ProcessState.ExitCode())
log.Debug("project server (pid: %d) exited with code %d", pid, projectServerCmd.ProcessState.ExitCode())
} else {
log.Debug("project server exited")
log.Debug("project server (pid: %d) exited", pid)
}
log.Debug("isDeliberateRestart: %t", isDeliberateRestart)
log.Debug("isDeliberateRestart: %t, pid: %d", isDeliberateRestart, pid)
if !isDeliberateRestart {
return
}
Expand All @@ -186,31 +190,32 @@ Examples:
if isDeliberateRestart {
isDeliberateRestart = false
log.Trace("restarting project server")
projectServerCmd, err = dev.CreateRunProjectCmd(ctx, log, theproject, websocketConn, dir, orgId, port)
projectServerCmd, err = dev.CreateRunProjectCmd(processCtx, log, theproject, websocketConn, dir, orgId, port)
if err != nil {
errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit()
}
if err := projectServerCmd.Start(); err != nil {
errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start project: %s", err))).ShowErrorAndExit()
}
pid = projectServerCmd.Process.Pid
log.Trace("restarted project server (pid: %d)", pid)
}
}
}()

teardown := func() {
watcher.Close(log)
websocketConn.Close()
dev.KillProjectServer(log, projectServerCmd, pid)
}

select {
case <-websocketConn.Done():
log.Info("live dev connection closed, shutting down")
dev.KillProjectServer(projectServerCmd)
watcher.Close(log)
teardown()
case <-ctx.Done():
log.Info("context done, shutting down")
websocketConn.Close()
watcher.Close(log)
case <-csys.CreateShutdownChannel():
log.Info("shutdown signal received, shutting down")
dev.KillProjectServer(projectServerCmd)
websocketConn.Close()
watcher.Close(log)
teardown()
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/sys v0.32.0
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
Expand Down
21 changes: 17 additions & 4 deletions internal/dev/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@ import (
"os"
"os/exec"
"strconv"
"syscall"
"time"

"github.com/agentuity/cli/internal/project"
"github.com/agentuity/cli/internal/util"
"github.com/agentuity/go-common/logger"
)

func KillProjectServer(projectServerCmd *exec.Cmd) {
func KillProjectServer(logger logger.Logger, projectServerCmd *exec.Cmd, pid int) {
if pid > 0 {
processes, err := getProcessTree(logger, pid)
if err != nil {
logger.Error("error getting process tree for parent (pid: %d): %s", pid, err)
}
for _, childPid := range processes {
logger.Debug("killing child process (pid: %d)", childPid)
kill(logger, childPid)
}
}
if projectServerCmd == nil || projectServerCmd.ProcessState == nil || projectServerCmd.ProcessState.Exited() {
logger.Debug("project server already exited (pid: %d)", pid)
kill(logger, pid)
return
}
ch := make(chan struct{}, 1)
Expand All @@ -26,8 +37,10 @@ func KillProjectServer(projectServerCmd *exec.Cmd) {
}()

if projectServerCmd.Process != nil {
// Try SIGINT first (Ctrl+C equivalent)
projectServerCmd.Process.Signal(syscall.SIGINT)
logger.Debug("killing parent process %d", pid)
if err := terminateProcess(logger, projectServerCmd); err != nil {
logger.Error("error terminating project server: %s", err)
}
}

// Wait a bit longer for SIGTERM to take effect
Expand Down
100 changes: 100 additions & 0 deletions internal/dev/dev_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build !windows
// +build !windows

package dev

import (
"bytes"
"fmt"
"os/exec"
"strconv"
"strings"
"syscall"
"time"

"github.com/agentuity/go-common/logger"
)

func terminateProcess(logger logger.Logger, cmd *exec.Cmd) error {
logger.Debug("terminateProcess: %s", cmd)
if cmd.Process != nil {
// Get the process group ID (negative PID)
pgid, err := syscall.Getpgid(cmd.Process.Pid)
if err != nil {
// If we can't get the process group, just kill the process directly
cmd.Process.Signal(syscall.SIGINT)
} else {
// Kill the entire process group
syscall.Kill(-pgid, syscall.SIGINT)
}

// Wait a short time for graceful shutdown
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()

select {
case <-time.After(5 * time.Second):
// If process hasn't terminated, use SIGKILL on the process group
if err == nil {
// Kill the entire process group with SIGKILL
syscall.Kill(-pgid, syscall.SIGKILL)
} else {
// Fallback to just killing the process
cmd.Process.Signal(syscall.SIGKILL)
}
case <-done:
// Process terminated gracefully
}
}
return nil
}

// getProcessTree returns a list of all descendant PIDs of the given parent PID.
func getProcessTree(logger logger.Logger, parentPID int) ([]int, error) {
logger.Debug("getting process tree for parent (pid: %d)", parentPID)
cmd := exec.Command("ps", "-eo", "pid,ppid") // works on both macOS and Linux
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
logger.Debug("failed to run ps: %s", err)
return nil, fmt.Errorf("failed to run ps: %w", err)
}

lines := strings.Split(out.String(), "\n")
pidMap := make(map[int][]int) // PPID -> []PID

for _, line := range lines[1:] { // skip header
fields := strings.Fields(line)
if len(fields) != 2 {
continue
}

pid, err1 := strconv.Atoi(fields[0])
ppid, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil {
continue
}

pidMap[ppid] = append(pidMap[ppid], pid)
}

// Recursively collect descendants
var collect func(int)
descendants := []int{}
collect = func(ppid int) {
for _, child := range pidMap[ppid] {
descendants = append(descendants, child)
collect(child)
}
}
collect(parentPID)

return descendants, nil
}

func kill(logger logger.Logger, pid int) error {
logger.Debug("killing process (pid: %d)", pid)
return syscall.Kill(pid, syscall.SIGTERM)
}
Loading
Loading