diff --git a/exec/compiler.go b/exec/compiler.go new file mode 100644 index 0000000..29b57b9 --- /dev/null +++ b/exec/compiler.go @@ -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 +} + +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 +} diff --git a/exec/supervise_loop.go b/exec/supervise_loop.go index d6a441a..aa6dce6 100644 --- a/exec/supervise_loop.go +++ b/exec/supervise_loop.go @@ -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) { @@ -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 @@ -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() @@ -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 @@ -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() @@ -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() @@ -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 } } diff --git a/exec/supervised.go b/exec/supervised.go index 821950d..f64fa8f 100644 --- a/exec/supervised.go +++ b/exec/supervised.go @@ -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" diff --git a/exec/supervised_test.go b/exec/supervised_test.go index a17de7f..6000a83 100644 --- a/exec/supervised_test.go +++ b/exec/supervised_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "time" . "github.com/onsi/ginkgo/v2" @@ -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)) @@ -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()) }) })