From a90d8d35ee1c424c6b75352542d02286f1234361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E6=99=BA=E8=B6=850668000959?= Date: Thu, 4 Jun 2026 17:50:54 +0800 Subject: [PATCH] fix(pid): verify process identity in singleton PID check isProcessRunning() previously only checked whether a PID existed via signal(0)/OpenProcess, without confirming the process was actually picoclaw. When the PID was reused by an unrelated process (e.g., systemd-resolved after a kill -9), the gateway would refuse to start with 'already running'. Add isPicoclawProcess() that verifies the process name matches picoclaw: - Unix: reads /proc//comm - Windows: calls QueryFullProcessImageNameW If the running process is not picoclaw, treat the PID file as stale and proceed with normal startup. Falls back to trusting the liveness check when identity verification is unavailable (e.g., /proc unreadable, API call fails). Fixes #2720. --- pkg/pid/pidfile.go | 9 ++++++++- pkg/pid/pidfile_unix.go | 17 ++++++++++++++++- pkg/pid/pidfile_windows.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go index 00601195f..3efc45f09 100644 --- a/pkg/pid/pidfile.go +++ b/pkg/pid/pidfile.go @@ -64,7 +64,14 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) { // pass the isProcessRunning check, blocking new gateway starts. // Treat recorded PID 1 as always stale. if data.PID != 1 && isProcessRunning(data.PID) { - return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) + // Verify the process is actually a picoclaw instance. + // If the PID was reused by an unrelated process + // (e.g. systemd-resolved after a kill -9), treat + // the PID file as stale and proceed with startup. + if isPicoclawProcess(data.PID) { + return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) + } + logger.Warnf("found pid file (PID: %d) but process is not picoclaw", data.PID) } logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) } diff --git a/pkg/pid/pidfile_unix.go b/pkg/pid/pidfile_unix.go index 7bc53b752..35a098ddf 100644 --- a/pkg/pid/pidfile_unix.go +++ b/pkg/pid/pidfile_unix.go @@ -4,7 +4,9 @@ package pid import ( "errors" + "fmt" "os" + "strings" "syscall" ) @@ -18,7 +20,7 @@ func isProcessRunning(pid int) bool { if err != nil { return false } - // Signal(nil) does not kill the process but checks existence on Unix. + // Signal(0) does not kill the process but checks existence on Unix. err = p.Signal(syscall.Signal(0)) if err == nil { return true @@ -27,3 +29,16 @@ func isProcessRunning(pid int) bool { // EPERM means the process exists but we are not allowed to signal it. return errors.As(err, &errno) && errno == syscall.EPERM } + +// isPicoclawProcess reads /proc//comm to confirm the process name +// contains "picoclaw". Returns false when the comm file can be read and +// the name does not match (e.g., PID was reused by an unrelated process). +// Returns true if /proc//comm is unreadable so the call site falls +// back to trusting the liveness check alone. +func isPicoclawProcess(pid int) bool { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return true // cannot verify — trust liveness check + } + return strings.Contains(strings.TrimSpace(string(data)), "picoclaw") +} diff --git a/pkg/pid/pidfile_windows.go b/pkg/pid/pidfile_windows.go index 6d8b79552..5d03eafd8 100644 --- a/pkg/pid/pidfile_windows.go +++ b/pkg/pid/pidfile_windows.go @@ -3,6 +3,7 @@ package pid import ( + "strings" "syscall" "unsafe" ) @@ -12,6 +13,7 @@ var ( procOpenProcess = kernel32.NewProc("OpenProcess") procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess") procCloseHandle = kernel32.NewProc("CloseHandle") + procQueryFullProcessImageNameW = kernel32.NewProc("QueryFullProcessImageNameW") processQueryLimitedInformation = uint32(0x1000) stillActive = uint32(259) ) @@ -40,3 +42,33 @@ func isProcessRunning(pid int) bool { } return exitCode == stillActive } + +// isPicoclawProcess uses QueryFullProcessImageNameW to confirm the +// process image name contains "picoclaw". Returns false when the name +// clearly does not match. Returns true if the query fails, falling +// back to trusting the liveness check alone. +func isPicoclawProcess(pid int) bool { + handle, _, _ := procOpenProcess.Call( + uintptr(processQueryLimitedInformation), + 0, + uintptr(pid), + ) + if handle == 0 { + return true // cannot open — trust liveness check + } + defer procCloseHandle.Call(handle) + + var buf [260]uint16 + var size uint32 = 260 + ret, _, _ := procQueryFullProcessImageNameW.Call( + uintptr(handle), + 0, // WIN32_NAME_FORMAT + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + ) + if ret == 0 { + return true // cannot verify — trust liveness check + } + name := strings.ToLower(syscall.UTF16ToString(buf[:size])) + return strings.Contains(name, "picoclaw") +}