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
registerbuilds one*grpc.Serverand oneserveStateat wiring and captures both in theWithStart/WithStop/WithStatusclosures. It returns the server so the application can register its services on it, once.startcreates a freshnet.Listenerper invocation, which is correct, and hands it to the captured server.- After
GracefulStop,srv.Serve(lis)returnsgrpc.ErrServerStopped— and closes the listener it was handed. Measured: a fresh listener accepted no connections and reporteduse of closed network connection. - The serve goroutine filters
grpc.ErrServerStopped, soserveState.exitErrstays nil and the restart reads as a clean start. serveState.exitErris never cleared between generations, so a previous generation's error keeps being reported byStatusafter 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.