diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3d72ace94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always use LF line endings regardless of OS. +*.sh text eol=lf +docker/entrypoint.sh text eol=lf diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b6fc724b5..6fafb5150 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js exit 0 fi +# Remove stale PID file from a previous container run. +# After docker kill / OOM / crash the PID file may linger on the bind-mounted +# volume and block the next gateway start (the recorded PID could collide with +# an unrelated process inside the new container). +rm -f "${HOME}/.picoclaw/.picoclaw.pid" + exec picoclaw gateway "$@" diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 9b39c85cf..1b3be8bd3 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, return fmt.Errorf("open process for job assignment: %w", err) } - if err := windows.AssignProcessToJobObject(job, proc); err != nil { + if err = windows.AssignProcessToJobObject(job, proc); err != nil { _ = windows.CloseHandle(proc) _ = windows.CloseHandle(job) if resources.token != 0 { diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go index f7c1f42b2..00601195f 100644 --- a/pkg/pid/pidfile.go +++ b/pkg/pid/pidfile.go @@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) { if data, err := readPidFileUnlocked(pidPath); err == nil { if os.Getpid() != data.PID { logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) - if isProcessRunning(data.PID) { + // PID 1 is typically init/systemd on the host or the entrypoint + // inside a container. When a container stops and leaves behind a + // PID file on a shared volume, the host's PID 1 (init) would + // 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) } logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) @@ -124,6 +129,14 @@ func ReadPidFileWithCheck(homePath string) *PidFileData { return nil } + // Treat PID 1 as stale when we are not PID 1 ourselves (container + // leftover on a shared volume — host PID 1 is init, not gateway). + if data.PID == 1 && os.Getpid() != 1 { + logger.Debugf("stale container PID 1, remove pid file: %s", pidPath) + os.Remove(pidPath) + return nil + } + if !isProcessRunning(data.PID) { logger.Debugf("process not running, remove pid file: %s", pidPath) os.Remove(pidPath) diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go index 2da44bbbc..2d3c11f63 100644 --- a/pkg/pid/pidfile_test.go +++ b/pkg/pid/pidfile_test.go @@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) { } } +// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1 +// (typical container entrypoint) is treated as stale and overwritten. +func TestWritePidFileContainerPID1(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file +// with PID 1 is treated as stale and cleaned up. +func TestReadPidFileWithCheckContainerPID1(t *testing.T) { + if os.Getpid() == 1 { + t.Skip("test not meaningful when running as PID 1") + } + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for PID 1 leftover") + } + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID 1 leftover file should be removed") + } +} + // TestReadPidFileUnlockedInvalidJSON returns error for malformed content. func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { dir := tmpDir(t)