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
153 changes: 15 additions & 138 deletions cmd/clear.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"

"github.com/codefly-dev/cli/cmd/common"
"github.com/codefly-dev/cli/pkg/processgroup"
Expand All @@ -21,85 +16,6 @@ import (
"github.com/spf13/cobra"
)

// codeflyOwnedPIDs returns the PIDs of running codefly-owned processes: the
// `codefly` CLI binary itself and any agent binary under ~/.codefly/agents/.
// It matches on the EXECUTABLE path, never on a substring of the full command
// line, so unrelated processes that merely mention the repo path are spared.
// The caller's own PID (self) is always excluded.
func codeflyOwnedPIDs(ctx context.Context, self int, scope processgroup.Scope) ([]int, error) {
// `command=` prints the full argv; the first token is the executable.
out, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,command=").Output()
if err != nil {
return nil, fmt.Errorf("list processes: %w", err)
}
pids := parseCodeflyOwnedPIDs(out, self)
if scope.All() {
return pids, nil
}
// A scoped kill only signals codefly processes belonging to this workspace's
// run. An agent or CLI whose workspace cannot be resolved belongs to nobody
// we can name, so a scoped kill leaves it alone. Attribution follows the run
// that launched the process, not its working directory: an agent serving a
// composed module runs from that module's checkout, outside the workspace
// that composed it.
kept := make([]int, 0, len(pids))
for _, pid := range pids {
workspace, ok := processgroup.WorkspaceOfProcess(pid)
if ok && scope.Includes(workspace) {
kept = append(kept, pid)
}
}
return kept, nil
}

func parseCodeflyOwnedPIDs(out []byte, self int) []int {
var pids []int
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
pid, convErr := strconv.Atoi(fields[0])
if convErr != nil || pid == self {
continue
}
exePath := fields[1]
owned := filepath.Base(exePath) == "codefly" || strings.Contains(exePath, "/.codefly/agents/")
if owned {
pids = append(pids, pid)
}
}
return pids
}

// killedProcessSettle bounds how long clear waits for the codefly processes it
// SIGKILLed to actually exit before it sweeps for orphaned native services. Agents
// die promptly under SIGKILL; the cap only guards against a wedged process holding
// up the command indefinitely.
const killedProcessSettle = 3 * time.Second

// waitProcessesExited blocks until every pid has been fully reaped (kill(0) fails
// with ESRCH) or the deadline passes, whichever comes first. A pid we cannot signal
// (still present) counts as alive; only a genuinely gone process lets the loop
// advance. The killed agents share this process's user, so a signal never fails
// with EPERM here.
func waitProcessesExited(ctx context.Context, pids []int, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for {
anyAlive := false
for _, pid := range pids {
if syscall.Kill(pid, 0) == nil {
anyAlive = true
break
}
}
if !anyAlive || ctx.Err() != nil || !time.Now().Before(deadline) {
return
}
time.Sleep(20 * time.Millisecond)
}
}

var (
clearKeepProcesses bool
clearKeepContainers bool
Expand Down Expand Up @@ -215,13 +131,16 @@ func clearCommand(ctx context.Context, args []string, options clearOptions) (ret

if options.keepProcesses {
w.Info("keeping processes (--keep-processes)")
} else {
failures = append(failures, killCodeflyProcesses(ctx, w, options)...)
}

failures = append(failures, sweepStaleState(ctx, w, options)...)
failures = append(failures, reapOrphanedDevServers(ctx, w, options)...)
failures = append(failures, reapOrphanedNativeServices(ctx, w, options)...)
managedFailures := sweepStaleState(ctx, w, options)
failures = append(failures, managedFailures...)
// The fallback scans must not turn a failed registry authentication into
// permission to signal the same group by its executable signature.
if len(managedFailures) == 0 {
failures = append(failures, reapOrphanedDevServers(ctx, w, options)...)
failures = append(failures, reapOrphanedNativeServices(ctx, w, options)...)
}

if options.keepContainers {
return errors.Join(failures...)
Expand Down Expand Up @@ -264,60 +183,18 @@ func clearCommand(ctx context.Context, args []string, options clearOptions) (ret
return errors.Join(failures...)
}

// killCodeflyProcesses kills every codefly-owned process in scope, or lists
// them on a dry run, and waits for the killed ones to exit.
func killCodeflyProcesses(ctx context.Context, w *wool.Wool, options clearOptions) []error {
var failures []error
// Match codefly-owned processes by their EXECUTABLE, not by a substring
// over the whole command line. The old `ps aux | grep codefly.dev`
// matched any process whose argv merely mentioned the repo path — an
// editor, a `tail -f`, a build — and kill -9'd it. Agent binaries always
// live under ~/.codefly/agents/, and the CLI itself is the `codefly`
// executable; nothing else qualifies.
self := os.Getpid()
pids, err := codeflyOwnedPIDs(ctx, self, options.scope)
if err != nil {
w.Warn("cannot enumerate codefly processes", wool.ErrField(err))
failures = append(failures, err)
}
switch {
case options.dryRun:
w.Info("would kill codefly processes", wool.Field("count", len(pids)), wool.Field("pids", pids))
case len(pids) == 0:
w.Info("no codefly processes running")
default:
killed := 0
for _, pid := range pids {
p, findErr := os.FindProcess(pid)
if findErr != nil {
failures = append(failures, fmt.Errorf("find process %d: %w", pid, findErr))
continue
}
if killErr := p.Kill(); killErr != nil {
failures = append(failures, fmt.Errorf("kill process %d: %w", pid, killErr))
continue
}
killed++
}
w.Info("killed codefly processes", wool.Field("killed", killed), wool.Field("total", len(pids)))
// SIGKILL is asynchronous: the agents we just signalled are not yet
// reaped, so the native binaries they supervised have not yet
// reparented to init. The orphan sweeps below decide a group is
// reapable only once its supervisor is gone (groupIsStale), so racing
// them against a still-dying agent would leave the just-orphaned
// binary looking supervised and skip it. Wait for the agents to exit
// before sweeping so a `clear` that killed a live run still reaps the
// natives it just orphaned.
waitProcessesExited(ctx, pids, killedProcessSettle)
}
return failures
}

// sweepStaleState removes the state crashed or exited CLIs left behind —
// per-spawn UDS sockets under /tmp/codefly-uds and process-group tracking
// files under ~/.codefly/runs — or counts it on a dry run.
func sweepStaleState(ctx context.Context, w *wool.Wool, options clearOptions) []error {
if options.dryRun {
if !options.keepProcesses {
groups, err := processgroup.PreviewManagedProcessGroups(ctx, options.scope)
if err != nil {
return []error{err}
}
w.Info("would stop registered process groups", wool.Field("count", len(groups)), wool.Field("pgids", groups))
}
if n := manager.CountStaleAgentSockets(); n > 0 {
w.Info("would remove stale agent sockets", wool.Field("count", n))
} else {
Expand Down
96 changes: 1 addition & 95 deletions cmd/clear_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
package cmd

import (
"context"
"os/exec"
"reflect"
"testing"
"time"

"github.com/codefly-dev/cli/pkg/processgroup"
)
import "testing"

func TestClearAndStopCommandsReturnErrors(t *testing.T) {
for name, command := range map[string]struct {
Expand Down Expand Up @@ -39,89 +31,3 @@ func TestStopOptionsDoNotInheritClearFlags(t *testing.T) {
t.Fatalf("stop options inherited clear state: %+v", got)
}
}

func TestParseCodeflyOwnedPIDs(t *testing.T) {
out := []byte(` 10 /usr/local/bin/codefly run service api
11 /Users/me/.codefly/agents/go/bin/agent serve
12 /usr/bin/editor /repo/codefly.dev/file.go
13 /usr/local/bin/codefly stop
bad /usr/local/bin/codefly
`)
if got, want := parseCodeflyOwnedPIDs(out, 13), []int{10, 11}; !reflect.DeepEqual(got, want) {
t.Fatalf("owned PIDs = %v, want %v", got, want)
}
}

func TestCodeflyOwnedPIDsReturnsProcessErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := codeflyOwnedPIDs(ctx, -1, processgroup.AllWorkspaces()); err == nil {
t.Fatal("expected cancelled process enumeration to return an error")
}
}

// TestWaitProcessesExitedBlocksUntilGone guards the reparent race: clear must not
// sweep for orphaned natives until the agents it SIGKILLed have actually exited.
// A no-op wait (the original bug) would return immediately while the process is
// still alive, so the first half asserts the wait genuinely blocks; the second
// asserts it returns promptly once the process is gone.
func TestWaitProcessesExitedBlocksUntilGone(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
pid := cmd.Process.Pid
reaped := make(chan struct{})
go func() {
_, _ = cmd.Process.Wait()
close(reaped)
}()
t.Cleanup(func() {
_ = cmd.Process.Kill()
<-reaped
})

start := time.Now()
waitProcessesExited(context.Background(), []int{pid}, 300*time.Millisecond)
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
t.Fatalf("waitProcessesExited returned after %s while the process was still alive; expected to wait to the timeout", elapsed)
}

if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
<-reaped

start = time.Now()
waitProcessesExited(context.Background(), []int{pid}, 5*time.Second)
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("waitProcessesExited blocked %s after the process exited; expected a prompt return", elapsed)
}
}

// TestWaitProcessesExitedHonorsCancelledContext ensures a wedged process cannot
// hang clear once the command's context is cancelled (e.g. a second Ctrl-C).
func TestWaitProcessesExitedHonorsCancelledContext(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
pid := cmd.Process.Pid
reaped := make(chan struct{})
go func() {
_, _ = cmd.Process.Wait()
close(reaped)
}()
t.Cleanup(func() {
_ = cmd.Process.Kill()
<-reaped
})

ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
waitProcessesExited(ctx, []int{pid}, time.Minute)
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("waitProcessesExited ignored the cancelled context and blocked %s", elapsed)
}
}
36 changes: 33 additions & 3 deletions cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *wo
// behind the materialization gate like every other check that loads them.
checkRunnableBindingDeclines(ctx, ws, env, report)

scope, requiredBy := checkScope(ctx, ws, opts.module, opts.service, report)
scope, requiredBy := checkScope(ctx, ws, env, opts.module, opts.service, report)
if scope == nil {
return report
}
Expand Down Expand Up @@ -790,7 +790,11 @@ func checkSecretProviders(env *environments.Environment, report *workspaceReadin
// acts on one unit must not be judged by a sibling unit's missing
// configuration. --service wins when both are given, being the narrower of the
// two.
func checkScope(ctx context.Context, ws *resources.Workspace, moduleName, serviceName string, report *workspaceReadinessReport) ([]*resources.Service, map[string][]string) {
func checkScope(ctx context.Context, ws *resources.Workspace, env *environments.Environment, moduleName, serviceName string, report *workspaceReadinessReport) ([]*resources.Service, map[string][]string) {
if err := environments.ValidateEnvironmentExclusions(ctx, ws, env); err != nil {
report.add(codeWorkspaceInvalid, "services", "fail", err.Error(), "correct the selected environment's exclude-services declarations")
return nil, nil
}
services, err := ws.LoadServices(ctx)
if err != nil {
report.add(codeWorkspaceInvalid, "services", "fail",
Expand Down Expand Up @@ -841,6 +845,21 @@ func checkScope(ctx context.Context, ws *resources.Workspace, moduleName, servic
scope = []*resources.Service{svc}
report.Service = serviceUnique(svc)
}
excluded := env.ExcludedServiceUniques()
selected := make([]*resources.Service, 0, len(scope))
for _, svc := range scope {
if slices.Contains(excluded, serviceUnique(svc)) {
if serviceName != "" {
report.add(codeServiceNotFound, "service", "fail",
fmt.Sprintf("environment %q excludes %s", env.Name, serviceUnique(svc)),
"select a service included in this environment")
return nil, nil
}
continue
}
selected = append(selected, svc)
}
scope = selected
requiredBy := make(map[string][]string)
for _, svc := range scope {
for _, dep := range svc.WorkspaceConfigurationDependencies {
Expand Down Expand Up @@ -1106,6 +1125,12 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env
// so a second generated group needs no second exemption.
delete(provided.Ambiguous, info.Name)
if _, authored := byName[info.Name]; authored {
// Generated binding decisions must judge the values the
// invocation delivers, including Core-applied overrides.
// Keep the authored inventory for provenance and conflicts.
if info.Name == generate.RunnableBindingsGroup {
byName[info.Name] = info
}
continue
}
byName[info.Name] = info
Expand Down Expand Up @@ -1268,7 +1293,7 @@ func checkRequiredWorkspaceConfiguration(ctx context.Context, ws *resources.Work
// no runnable binding, so the group absent or holding no value is its answer.
// A selected binding the group does not hold fails, naming the generation
// that writes it. It reports whether it judged the group; a group that holds
// every selected binding is left to the checks every group gets.
// every selected binding must also match the generator's prepared values.
func checkRunnableBindingsGroup(ctx context.Context, ws *resources.Workspace, env *environments.Environment, name string, info *basev0.ConfigurationInformation, relCfgDir, requiredBy string, report *workspaceReadinessReport) bool {
selection, err := generate.SelectRunnableBindings(ctx, ws, env)
if err != nil {
Expand Down Expand Up @@ -1301,6 +1326,11 @@ func checkRunnableBindingsGroup(ctx context.Context, ws *resources.Workspace, en
report.add("", "workspace configuration "+name, "ok",
fmt.Sprintf("environment %s prepares no runnable binding, so %q holds none (required by %s)", env.Name, name, requiredBy), "")
default:
if err := generate.CheckRunnableBindings(ctx, ws, env, info); err != nil {
report.add(codeConfigurationInvalid, "workspace configuration "+name, checkStatusFail,
fmt.Sprintf("cannot use generated runnable bindings: %v (required by %s)", err, requiredBy), remedy)
return true
}
return false
}
return true
Expand Down
Loading
Loading