From f346e77daa2fc4d9181988c2ee7bb6715db91f3c Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 08:38:17 +0100 Subject: [PATCH] fix: reap child processes on session stop/kill to prevent zombies captureOutput goroutine could exit via the done channel without calling cmd.Wait(), leaving terminated child processes as zombies in the process table. Moved cmd.Wait() and p.Close() into a defer so the child is always reaped regardless of exit path. --- internal/daemon/server.go | 48 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 4f17e30..794e3c8 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -477,6 +477,30 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { f := p.File() + defer func() { + cmd.Wait() + p.Close() + + s.mu.Lock() + defer s.mu.Unlock() + + h.pty = nil + h.cmd = nil + h.done = nil + h.frameDetector = nil + h.responder = nil + + h.state = StateStopped + now := time.Now() + h.stoppedAt = &now + + if meta, err := s.storage.LoadMeta(name); err == nil { + meta.State = StateStopped + meta.StoppedAt = &now + s.storage.SaveMeta(name, meta) + } + }() + if detector != nil { defer func() { if pending := detector.Flush(); len(pending) > 0 { @@ -513,31 +537,9 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { } } if err != nil && !isTimeout(err) { - break + return } } - - cmd.Wait() - p.Close() - - s.mu.Lock() - defer s.mu.Unlock() - - h.pty = nil - h.cmd = nil - h.done = nil - h.frameDetector = nil - h.responder = nil - - h.state = StateStopped - now := time.Now() - h.stoppedAt = &now - - if meta, err := s.storage.LoadMeta(name); err == nil { - meta.State = StateStopped - meta.StoppedAt = &now - s.storage.SaveMeta(name, meta) - } } func isTimeout(err error) bool {