carlos / platform Public

edge: PurgeListener.Close no longer races the Serve goroutine

Close delegated to http.Server.Close, which only closes listeners its
own Serve call has already registered — inside a map net/http populates
at the TOP of Serve, on entry. ListenPurge starts Serve in a goroutine
it never waits on, so a Close arriving before that goroutine gets
scheduled found nothing registered and silently no-op'd: the listener
was never closed, the socket file never unlinked, and the abandoned
goroutine went on to Accept on a socket nobody asked it to stop.

Found while wiring UnitBackend's purge listener (next commit): its
Start/Stop cycle opens and closes a purge listener back-to-back around
one systemctl call, with none of the hundreds of milliseconds of health-
wait slack that happened to keep ExecBackend's equivalent test off this
race. Closing p.ln directly sidesteps the tracking map entirely — a
raw unix listener always unlinks its socket file on Close, regardless
of whether the Serve goroutine ever got scheduled.

Regression test: Close called immediately after ListenPurge returns,
with no synchronization giving the Serve goroutine a chance to run
first — reproduced 5/5 before the fix, clean after.
Paul Campbell 1b161578f59485dedb4dab5fb27b929dab452315 parent b3b5151
2 files changed, +47 −2
  • internal/edge/purge.go +19 −2
  • internal/edge/purge_test.go +28 −0
diff --git a/internal/edge/purge.go b/internal/edge/purge.go
index 4817763e7..8de60d8dc 100644
--- a/internal/edge/purge.go
+++ b/internal/edge/purge.go
@@ -101,10 +101,27 @@ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error)
srv := &http.Server{Handler: mux}
p := &PurgeListener{ln: ln, srv: srv}
- go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed
+ // Close (below) closes p.ln directly, not through srv — so the error
+ // Serve returns here is whatever a closed-listener Accept produces, not
+ // necessarily http.ErrServerClosed. Either way it's expected and unused.
+ go srv.Serve(ln) //nolint:errcheck
return p, nil
}
+// Close stops accepting purge requests and removes the socket file.
+//
+// It closes p.ln directly rather than p.srv.Close(): net/http's Server only
+// closes listeners its own Serve call has already registered (in an
+// internal map populated by Serve itself, on entry — see trackListener in
+// net/http/server.go), and ListenPurge starts that Serve in a goroutine it
+// does not wait on. A Close arriving before that goroutine has been
+// scheduled loses the race and finds nothing registered to close: Serve
+// then proceeds to Accept on a listener nobody asked it to stop, and the
+// socket file it owns is never unlinked. Closing the raw net.Listener sidesteps
+// the tracking map entirely — it always unlinks (Go's unix listener does
+// this on Close for any listener it created via Listen) and makes the
+// goroutine's next Accept fail, which ends Serve regardless of whether it
+// had reached trackListener yet.
func (p *PurgeListener) Close() error {
- return p.srv.Close()
+ return p.ln.Close()
}
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go
index 6d126fa2f..9393d5349 100644
--- a/internal/edge/purge_test.go
+++ b/internal/edge/purge_test.go
@@ -95,6 +95,34 @@ func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
}
}
+// Close, called immediately after ListenPurge returns (before the Serve
+// goroutine it started has necessarily run at all), must still unlink the
+// socket file. PurgeListener.Close used to delegate to http.Server.Close,
+// which only closes listeners its own Serve call has already registered in
+// its internal tracking map (net/http's trackListener, set inside Serve
+// itself) — a real race, since Serve runs in a goroutine ListenPurge doesn't
+// wait on. Losing that race silently no-ops Close: the goroutine's later
+// Serve(ln) call then Accepts on a listener nobody ever asked to stop, and
+// the socket file is never removed. A caller like UnitBackend.Start/Stop
+// that opens and closes a purge listener back-to-back around a single unit
+// start hits this every time, not just occasionally.
+func TestListenPurgeCloseImmediatelyAfterOpenRemovesSocketFile(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+
+ if err := pl.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ if _, err := os.Stat(sock); err == nil {
+ t.Fatal("expected the socket file removed immediately after Close, even called before the Serve goroutine could have run")
+ }
+}
+
func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "instance.sock")