feat: preventing process kill on port use

This commit is contained in:
Carlos Florêncio
2026-02-08 21:20:16 +00:00
parent c83e0ddd59
commit def9195802
3 changed files with 4 additions and 193 deletions
@@ -243,9 +243,8 @@ func findFreePort() (int, error) {
return port, nil
}
// ensurePortAvailable checks if the configured port is free. If not, it tries
// to diagnose and clean up stale processes. If the port is still unavailable,
// it falls back to an OS-assigned free port.
// ensurePortAvailable checks if the configured port is free. If not, it
// immediately falls back to an OS-assigned free port.
func (l *Launcher) ensurePortAvailable() error {
if err := checkPortAvailable(l.port); err == nil {
return nil
@@ -253,39 +252,14 @@ func (l *Launcher) ensurePortAvailable() error {
originalPort := l.port
// Port is in use — try to find and kill whatever is holding it
l.logger.Warn("port in use, attempting to find and kill stale process",
l.logger.Info("port already in use, selecting a free port",
zap.Int("port", l.port))
l.diagnosePID()
if killed := l.tryKillPortHolder(); killed {
// Wait briefly for the OS to release the port
time.Sleep(500 * time.Millisecond)
if err := checkPortAvailable(l.port); err == nil {
l.logger.Info("port freed after killing stale process", zap.Int("port", l.port))
return nil
}
}
// Brief wait for TIME_WAIT to expire (up to 3s)
l.logger.Warn("could not free port immediately, waiting for OS to release it",
zap.Int("port", l.port))
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if err := checkPortAvailable(l.port); err == nil {
l.logger.Info("port became available", zap.Int("port", l.port))
return nil
}
}
// Fall back to OS-assigned free port
freePort, err := findFreePort()
if err != nil {
return fmt.Errorf("port %d is in use and failed to find alternative: %w", originalPort, err)
}
l.logger.Warn("configured port unavailable, using alternative",
l.logger.Info("using alternative port",
zap.Int("original_port", originalPort),
zap.Int("new_port", freePort))
l.port = freePort
@@ -3,11 +3,6 @@
package launcher
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"syscall"
"go.uber.org/zap"
@@ -28,92 +23,3 @@ func (l *Launcher) gracefulStop(pid int) error {
func (l *Launcher) forceKill(pid int) {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
// diagnosePID logs detailed info about what is holding the port (best-effort, non-fatal).
func (l *Launcher) diagnosePID() {
portStr := fmt.Sprintf(":%d", l.port)
// Try lsof first
if out, err := exec.Command("lsof", "-i", portStr, "-P", "-n").CombinedOutput(); err == nil {
output := strings.TrimSpace(string(out))
if output != "" {
l.logger.Warn("lsof: process(es) holding port",
zap.Int("port", l.port),
zap.String("lsof_output", output))
return
}
}
// lsof found nothing — check netstat for TIME_WAIT / CLOSE_WAIT sockets
if out, err := exec.Command("netstat", "-an").CombinedOutput(); err == nil {
var matches []string
search := fmt.Sprintf(".%d ", l.port) // macOS netstat uses dot separator
search2 := fmt.Sprintf(":%d ", l.port) // Linux netstat uses colon
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, search) || strings.Contains(line, search2) {
matches = append(matches, strings.TrimSpace(line))
}
}
if len(matches) > 0 {
l.logger.Warn("netstat: socket(s) on port (likely TIME_WAIT)",
zap.Int("port", l.port),
zap.String("netstat_output", strings.Join(matches, "\n")))
return
}
}
// Test IPv4 vs IPv6 separately to identify which is blocked
v4Err := tryBind("tcp4", portStr)
v6Err := tryBind("tcp6", portStr)
l.logger.Warn("port in use but no process found — checking address families",
zap.Int("port", l.port),
zap.NamedError("ipv4_bind_err", v4Err),
zap.NamedError("ipv6_bind_err", v6Err))
}
// tryBind attempts to bind to the given network/address and returns any error.
func tryBind(network, addr string) error {
ln, err := net.Listen(network, addr)
if err != nil {
return err
}
_ = ln.Close()
return nil
}
// tryKillPortHolder tries to find and kill the process holding the port.
// Returns true if it found and signaled a process.
func (l *Launcher) tryKillPortHolder() bool {
out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", l.port)).CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) == "" {
return false
}
killed := false
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pid := 0
if _, err := fmt.Sscanf(line, "%d", &pid); err != nil || pid <= 0 {
continue
}
if pid == os.Getpid() {
continue
}
l.logger.Warn("killing stale process on port",
zap.Int("port", l.port),
zap.Int("pid", pid))
if proc, err := os.FindProcess(pid); err == nil {
if err := proc.Signal(syscall.SIGTERM); err != nil {
l.logger.Debug("SIGTERM failed, trying SIGKILL",
zap.Int("pid", pid),
zap.Error(err))
_ = proc.Kill()
}
killed = true
}
}
return killed
}
@@ -3,10 +3,7 @@
package launcher
import (
"fmt"
"os"
"os/exec"
"strings"
"go.uber.org/zap"
)
@@ -33,69 +30,3 @@ func (l *Launcher) forceKill(pid int) {
_ = proc.Kill()
}
}
// diagnosePID uses netstat to log what is holding the port (best-effort, non-fatal).
func (l *Launcher) diagnosePID() {
out, err := exec.Command("netstat", "-ano").CombinedOutput()
if err != nil {
l.logger.Debug("netstat failed", zap.Error(err))
return
}
portStr := fmt.Sprintf(":%d ", l.port)
var matches []string
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, portStr) {
matches = append(matches, strings.TrimSpace(line))
}
}
if len(matches) == 0 {
l.logger.Warn("port in use but netstat found no process — likely TIME_WAIT or kernel hold",
zap.Int("port", l.port))
} else {
l.logger.Warn("process(es) holding port",
zap.Int("port", l.port),
zap.String("netstat_output", strings.Join(matches, "\n")))
}
}
// tryKillPortHolder tries to find and kill the process holding the port using netstat + taskkill.
// Returns true if it found and signaled a process.
func (l *Launcher) tryKillPortHolder() bool {
out, err := exec.Command("netstat", "-ano").CombinedOutput()
if err != nil {
return false
}
portStr := fmt.Sprintf(":%d ", l.port)
selfPid := os.Getpid()
killed := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, portStr) || !strings.Contains(line, "LISTENING") {
continue
}
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 5 {
continue
}
pid := 0
if _, err := fmt.Sscanf(fields[len(fields)-1], "%d", &pid); err != nil || pid <= 0 {
continue
}
if pid == selfPid {
continue
}
l.logger.Warn("killing stale process on port",
zap.Int("port", l.port),
zap.Int("pid", pid))
// taskkill /F /PID <pid>
if killErr := exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid)).Run(); killErr != nil {
l.logger.Debug("taskkill failed", zap.Int("pid", pid), zap.Error(killErr))
} else {
killed = true
}
}
return killed
}