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
81 changes: 81 additions & 0 deletions exec/compiler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package exec

import (
"path/filepath"
"strings"

gops "github.com/shirou/gopsutil/v3/process"
)

var compilerExecutableBasenames = map[string]struct{}{
"asm": {},
"babel": {},
"cgo": {},
"compile": {},
"esbuild": {},
"link": {},
"parcel": {},
"rollup": {},
"swc": {},
"tsc": {},
"webpack": {},
}

var compilerArgumentBasenames = map[string]struct{}{
"babel": {},
"esbuild": {},
"parcel": {},
"rollup": {},
"swc": {},
"tsc": {},
"webpack": {},
}

func detectCompilers(root int32) (bool, error) {
for _, pid := range collectPids(root) {
proc, err := gops.NewProcess(pid)
if err != nil {
continue
}
if name, err := proc.Name(); err == nil && isCompilerExecutable(name) {
return true, nil
}
if exe, err := proc.Exe(); err == nil && isCompilerExecutable(exe) {
return true, nil
}
if args, err := proc.CmdlineSlice(); err == nil && isCompilerCommandLine(args) {
return true, nil
}
}
return false, nil
Comment on lines +34 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Surface probe failures when compiler detection has no visibility.

detectCompilers currently swallows all gopsutil probe errors and always returns nil error. When inspection fails across the tree, the caller treats that as “not compiling”, which can advance startup state prematurely.

Proposed fix
 func detectCompilers(root int32) (bool, error) {
+	var firstErr error
+	visible := false
 	for _, pid := range collectPids(root) {
 		proc, err := gops.NewProcess(pid)
 		if err != nil {
+			if firstErr == nil {
+				firstErr = err
+			}
 			continue
 		}
-		if name, err := proc.Name(); err == nil && isCompilerExecutable(name) {
+		if name, err := proc.Name(); err == nil {
+			visible = true
+			if isCompilerExecutable(name) {
+				return true, nil
+			}
+		} else if firstErr == nil {
+			firstErr = err
+		}
+		if exe, err := proc.Exe(); err == nil {
+			visible = true
+			if isCompilerExecutable(exe) {
+				return true, nil
+			}
+		} else if firstErr == nil {
+			firstErr = err
+		}
+		if args, err := proc.CmdlineSlice(); err == nil {
+			visible = true
+			if isCompilerCommandLine(args) {
+				return true, nil
+			}
+		} else if firstErr == nil {
+			firstErr = err
+		}
-			return true, nil
-		}
-		if exe, err := proc.Exe(); err == nil && isCompilerExecutable(exe) {
-			return true, nil
-		}
-		if args, err := proc.CmdlineSlice(); err == nil && isCompilerCommandLine(args) {
-			return true, nil
-		}
 	}
+	if !visible && firstErr != nil {
+		return false, firstErr
+	}
 	return false, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func detectCompilers(root int32) (bool, error) {
for _, pid := range collectPids(root) {
proc, err := gops.NewProcess(pid)
if err != nil {
continue
}
if name, err := proc.Name(); err == nil && isCompilerExecutable(name) {
return true, nil
}
if exe, err := proc.Exe(); err == nil && isCompilerExecutable(exe) {
return true, nil
}
if args, err := proc.CmdlineSlice(); err == nil && isCompilerCommandLine(args) {
return true, nil
}
}
return false, nil
func detectCompilers(root int32) (bool, error) {
var firstErr error
visible := false
for _, pid := range collectPids(root) {
proc, err := gops.NewProcess(pid)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if name, err := proc.Name(); err == nil {
visible = true
if isCompilerExecutable(name) {
return true, nil
}
} else if firstErr == nil {
firstErr = err
}
if exe, err := proc.Exe(); err == nil {
visible = true
if isCompilerExecutable(exe) {
return true, nil
}
} else if firstErr == nil {
firstErr = err
}
if args, err := proc.CmdlineSlice(); err == nil {
visible = true
if isCompilerCommandLine(args) {
return true, nil
}
} else if firstErr == nil {
firstErr = err
}
}
if !visible && firstErr != nil {
return false, firstErr
}
return false, nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@exec/compiler.go` around lines 34 - 50, The detectCompilers function silently
ignores all errors from gopsutil probes (gops.NewProcess, proc.Name, proc.Exe,
proc.CmdlineSlice) by using continue statements and always returns nil error
regardless of inspection failures. Track errors during the iteration through
collectPids results, and when compiler detection has no visibility into the
process tree (all probes fail), return an appropriate error instead of nil so
callers can distinguish between "no compilers found" and "couldn't inspect
processes". This ensures the caller doesn't prematurely advance startup state
when inspection failures occur.

}

func isCompilerCommandLine(args []string) bool {
if len(args) == 0 {
return false
}
if isCompilerExecutable(args[0]) {
return true
}
for _, arg := range args[1:] {
if isCompilerArgument(arg) {
return true
}
}
return false
}

func isCompilerExecutable(value string) bool {
return hasCompilerBasename(value, compilerExecutableBasenames)
}

func isCompilerArgument(value string) bool {
return hasCompilerBasename(value, compilerArgumentBasenames)
}

func hasCompilerBasename(value string, matchers map[string]struct{}) bool {
base := strings.ToLower(filepath.Base(strings.TrimSpace(value)))
base = strings.TrimSuffix(base, ".exe")
_, ok := matchers[base]
return ok
}
61 changes: 44 additions & 17 deletions exec/supervise_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ var (
// portDetector reports the listening ports of the process tree rooted at pid.
type portDetector func(pid int32) ([]int, error)

// compilationDetector reports whether compiler/linker work is active in the
// process tree rooted at pid.
type compilationDetector func(pid int32) (bool, error)

// detectGroupPorts is the production detector: the listening ports across the
// supervised process's whole group/tree.
func detectGroupPorts(pid int32) ([]int, error) {
Expand Down Expand Up @@ -202,7 +206,7 @@ func (s *SupervisedProcess) runLoop() {
}

if s.opts.DetectPorts && proc.IsRunning() {
go s.watchPorts(proc, myGen, detectGroupPorts)
go s.watchPorts(proc, myGen, detectGroupPorts, detectCompilers)
}

<-runDone
Expand Down Expand Up @@ -261,16 +265,14 @@ func (s *SupervisedProcess) monitorLoop(done chan struct{}) {
}
}

// watchPorts polls for the TCP ports the current process tree is listening on
// for the lifetime of the run, recording the set (and marking the process
// port-bound, sticky across restarts) whenever it changes. It polls fast
// (portPollInterval) during the startup window (portFastWindow), then relaxes to
// portPollIntervalSlow, so a port that only binds after a cold `go run` compile —
// well past the window — is still picked up. A detected port, or portPromoteGrace
// elapsing without one, flips "starting"→"running" so neither a slow server nor a
// port-less worker is wedged in "starting". gen guards against a stale watcher
// from a previous run clobbering the current one.
func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetector) {
// watchPorts polls the current process tree for startup signals and listening
// ports for the lifetime of the run. During startup it reports active compiler
// or linker children as "compiling"; after compilation quiets down it reports
// "starting" while waiting for ports; a detected port or a quiet grace period
// promotes the run to "running". Once running, compiler activity never moves the
// lifecycle backwards. gen guards against a stale watcher from a previous run
// clobbering the current one.
func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetector, detectCompile compilationDetector) {
s.mu.RLock()
done := s.done
s.mu.RUnlock()
Expand All @@ -280,11 +282,12 @@ func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetect
}

start := time.Now()
promoteAt := start.Add(portPromoteGrace)
waitingSince := start
fastUntil := start.Add(portFastWindow)
for {
s.mu.RLock()
current := s.gen == gen && s.current == proc && !s.stopping
status := s.status
s.mu.RUnlock()
if !current || !proc.IsRunning() {
return
Expand All @@ -296,7 +299,16 @@ func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetect
s.promoteIfStarting(gen, proc)
return
}
compiling := false
if status != StatusRunning && len(ports) == 0 && detectCompile != nil {
if active, err := detectCompile(int32(pid)); err != nil {
log.Warnf("detect compiler activity for %s: %v", s.Name(), err)
} else {
compiling = active
}
}

now := time.Now()
s.mu.Lock()
if s.gen != gen || s.current != proc {
s.mu.Unlock()
Expand All @@ -308,8 +320,22 @@ func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetect
}
s.expectsPort = true
}
if s.status == StatusStarting && (len(ports) > 0 || !time.Now().Before(promoteAt)) {
s.status = StatusRunning
if s.status == StatusStarting || s.status == StatusCompiling {
switch {
case len(ports) > 0:
s.status = StatusRunning
case compiling:
s.status = StatusCompiling
waitingSince = time.Time{}
default:
if waitingSince.IsZero() {
waitingSince = now
}
s.status = StatusStarting
if !now.Before(waitingSince.Add(portPromoteGrace)) {
s.status = StatusRunning
}
}
}
s.mu.Unlock()

Expand All @@ -325,13 +351,14 @@ func (s *SupervisedProcess) watchPorts(proc *Process, gen int, detect portDetect
}
}

// promoteIfStarting flips a still-"starting" current run to "running" — used when
// promoteIfStarting flips a startup-phase current run to "running" — used when
// port detection can't continue (lsof failed) so a known server isn't left
// wedged in "starting". The gen/proc guard ignores a stale prior run.
// wedged in "starting" or "compiling". The gen/proc guard ignores a stale prior
// run.
func (s *SupervisedProcess) promoteIfStarting(gen int, proc *Process) {
s.mu.Lock()
defer s.mu.Unlock()
if s.gen == gen && s.current == proc && s.status == StatusStarting {
if s.gen == gen && s.current == proc && (s.status == StatusStarting || s.status == StatusCompiling) {
s.status = StatusRunning
}
}
Expand Down
1 change: 1 addition & 0 deletions exec/supervised.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type Status string
const (
StatusStopped Status = "stopped"
StatusStarting Status = "starting"
StatusCompiling Status = "compiling"
StatusRunning Status = "running"
StatusRestarting Status = "restarting"
StatusCrashed Status = "crashed"
Expand Down
60 changes: 58 additions & 2 deletions exec/supervised_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"

. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -177,7 +178,7 @@ var _ = Describe("SupervisedProcess", func() {
}
return []int{4321}, nil
}
go s.watchPorts(proc, gen, detect)
go s.watchPorts(proc, gen, detect, nil)

Eventually(s.Ports, 2*time.Second, 20*time.Millisecond).Should(Equal([]int{4321}))
Expect(s.Status()).To(Equal(StatusRunning))
Expand All @@ -189,11 +190,66 @@ var _ = Describe("SupervisedProcess", func() {
portFastWindow = 10 * time.Second
s, proc, gen := startWatched()
none := func(int32) ([]int, error) { return nil, nil }
go s.watchPorts(proc, gen, none)
go s.watchPorts(proc, gen, none, nil)

Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusRunning))
Expect(s.Ports()).To(BeEmpty())
})

It("reports compiling, then starting, then running during startup", func() {
s, proc, gen := startWatched()
var compiling atomic.Bool
compiling.Store(true)
none := func(int32) ([]int, error) { return nil, nil }
detectCompile := func(int32) (bool, error) { return compiling.Load(), nil }
go s.watchPorts(proc, gen, none, detectCompile)

Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusCompiling))
compiling.Store(false)
Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusStarting))
Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusRunning))
Expect(s.Ports()).To(BeEmpty())
})

It("keeps reporting compiling past the normal port promotion grace", func() {
s, proc, gen := startWatched()
var compiling atomic.Bool
compiling.Store(true)
none := func(int32) ([]int, error) { return nil, nil }
detectCompile := func(int32) (bool, error) { return compiling.Load(), nil }
go s.watchPorts(proc, gen, none, detectCompile)

Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusCompiling))
Consistently(s.Status, portPromoteGrace+40*time.Millisecond, 10*time.Millisecond).Should(Equal(StatusCompiling))
compiling.Store(false)
Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusStarting))
Eventually(s.Status, 1*time.Second, 20*time.Millisecond).Should(Equal(StatusRunning))
})

It("promotes to running when a port appears even while compiling", func() {
s, proc, gen := startWatched()
detect := func(int32) ([]int, error) { return []int{4321}, nil }
detectCompile := func(int32) (bool, error) { return true, nil }
go s.watchPorts(proc, gen, detect, detectCompile)

Eventually(s.Ports, 1*time.Second, 20*time.Millisecond).Should(Equal([]int{4321}))
Expect(s.Status()).To(Equal(StatusRunning))
})
})
})

var _ = Describe("compiler startup detection", func() {
It("matches compiler and linker process names", func() {
Expect(isCompilerExecutable("/tmp/go/pkg/tool/darwin_arm64/compile")).To(BeTrue())
Expect(isCompilerExecutable("link")).To(BeTrue())
Expect(isCompilerExecutable("compile.exe")).To(BeTrue())
})

It("matches common JavaScript compiler command lines", func() {
Expect(isCompilerCommandLine([]string{"node", "/workspace/node_modules/esbuild/bin/esbuild"})).To(BeTrue())
Expect(isCompilerCommandLine([]string{"/usr/local/bin/webpack"})).To(BeTrue())
Expect(isCompilerCommandLine([]string{"go", "run", "."})).To(BeFalse())
Expect(isCompilerCommandLine([]string{"npm", "run", "compile"})).To(BeFalse())
})
})

Expand Down
Loading