fix: both servers are single-use, so a restarted service serves nothing and reports success

Both server packages capture a single-use server at wiring time and hand a fresh listener to it on every StartFunc invocation. A restart therefore builds a new listener, gives it to a server that can never serve again, and reports success.

This is latent, not live. Neither grpc.Register nor http.Register installs a controls.RestartPolicy, so nothing in the shipped registration path restarts these services today. It becomes live the moment a caller composes the exported Start/Stop/Status functions with controls.WithRestartPolicy, which is a supported composition.

What happens

grpc/server.go

  • register builds one *grpc.Server and one serveState at wiring and captures both in the WithStart/WithStop/WithStatus closures. It returns the server so the application can register its services on it, once.
  • start creates a fresh net.Listener per invocation, which is correct, and hands it to the captured server.
  • After GracefulStop, srv.Serve(lis) returns grpc.ErrServerStopped — and closes the listener it was handed. Measured: a fresh listener accepted no connections and reported use of closed network connection.
  • The serve goroutine filters grpc.ErrServerStopped, so serveState.exitErr stays nil and the restart reads as a clean start.
  • serveState.exitErr is never cleared between generations, so a previous generation's error keeps being reported by Status after a later start.

http/server.go has the same structure: a captured *http.Server, a fresh listener per start, and http.ErrServerClosed filtered at http/server.go:369.

Why it is worth fixing rather than documenting

The composite failure is silent in both directions. Status can report healthy while nothing is listening, and a stale exitErr can report unhealthy after a successful start. With a restart policy present the two combine: the service restarts, the restart appears to succeed, the stale status keeps breaching the health threshold, and the loop continues until the policy is exhausted — without a log line naming the cause, because the only error that would have named it was filtered.

Direction, not a prescription

The registrations cannot be recovered from a stopped server. GetServiceInfo returns service names, method names and proto metadata but no handler implementations, so a fresh server cannot be rebuilt from the old one. Holding the registration intent as something replayable is therefore the shape that works, and a throwaway spike confirmed the whole cycle end to end: registration intent held as a func(*grpc.Server), generation 1 served a real health RPC, was gracefully stopped, and generation 2 was built fresh, replayed, and served a real RPC on a new address.

That implies a change to what Register returns, which under this repo's own rule is a feat rather than a refactor. Per-generation serveState falls out of the same change. The maintainers know constraints a spike does not, so the shape above is offered as evidence that a fix exists rather than as the design.

Verified against

origin/main at 719b1c42, go/controls v0.4.0, gRPC v1.83.1 as pinned (the spike resolved v1.83.2 from cache; same behaviour). WithRestartPolicy appears zero times in either grpc/server.go or http/server.go, which is what makes this latent.