From 62acd578e0f0793dc156346ce43b549f803904ed Mon Sep 17 00:00:00 2001 From: toller892 Date: Tue, 2 Jun 2026 08:30:59 +0800 Subject: [PATCH] fix: reap zombie child processes in container environments When soft-serve runs as PID 1 in a container without an init supervisor, orphaned descendant processes (e.g. git pack-objects left behind when a git parent exits) are reparented to PID 1 and become zombies because the Go runtime only tracks children spawned via os/exec. Add a periodic reaper goroutine that calls waitpid(-1, WNOHANG) every 10 seconds to clean up any zombie children. The reaper runs only on Linux (where the PID 1 container issue manifests) and is a no-op on other platforms. Fixes #891 --- cmd/soft/serve/reap_linux.go | 42 ++++++++++++++++++++++++++++++++++++ cmd/soft/serve/reap_other.go | 13 +++++++++++ cmd/soft/serve/serve.go | 5 +++++ 3 files changed, 60 insertions(+) create mode 100644 cmd/soft/serve/reap_linux.go create mode 100644 cmd/soft/serve/reap_other.go diff --git a/cmd/soft/serve/reap_linux.go b/cmd/soft/serve/reap_linux.go new file mode 100644 index 000000000..f3f9a3a28 --- /dev/null +++ b/cmd/soft/serve/reap_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package serve + +import ( + "context" + "time" + + "charm.land/log/v2" + "golang.org/x/sys/unix" +) + +// reapZombies periodically reaps zombie child processes. +// +// When soft-serve runs as PID 1 in a container, orphaned descendant +// processes (e.g. grandchild git pack-objects left behind when a git +// parent exits before waiting for them) are reparented to PID 1. +// Without an init system these processes become zombies because the +// Go runtime only tracks children spawned via os/exec, not reparented +// orphans. This goroutine periodically calls waitpid(-1, WNOHANG) to +// clean them up. +func reapZombies(ctx context.Context, logger *log.Logger) { + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + for { + var status unix.WaitStatus + pid, err := unix.Wait4(-1, &status, unix.WNOHANG, nil) + if err != nil || pid <= 0 { + break + } + logger.Debugf("reaped zombie child pid=%d status=%d", pid, status) + } + } + } + }() +} diff --git a/cmd/soft/serve/reap_other.go b/cmd/soft/serve/reap_other.go new file mode 100644 index 000000000..4a9aeca13 --- /dev/null +++ b/cmd/soft/serve/reap_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package serve + +import ( + "context" + + "charm.land/log/v2" +) + +// reapZombies is a no-op on non-Linux platforms. +// See reap_linux.go for the Linux implementation. +func reapZombies(_ context.Context, _ *log.Logger) {} diff --git a/cmd/soft/serve/serve.go b/cmd/soft/serve/serve.go index 7472f3d0b..d91b74bd1 100644 --- a/cmd/soft/serve/serve.go +++ b/cmd/soft/serve/serve.go @@ -75,6 +75,11 @@ var ( return fmt.Errorf("start server: %w", err) } + // Start zombie child reaper for container environments where + // soft-serve may run as PID 1 without an init supervisor. + // See https://github.com/charmbracelet/soft-serve/issues/891 + reapZombies(ctx, s.logger) + if syncHooks { be := backend.FromContext(ctx) if err := cmd.InitializeHooks(ctx, cfg, be); err != nil {