Find a capture a restart cannot reuse¶
A restart calls your StartFunc again but reuses its closure, so a server, a
listener or a supervisor built once at wiring is the previous run's by the
second call. singleuse is an analyzer that names the line where that
happens. It ships as a nested module of this repository and runs like go vet.
Run it¶
Every release of controls from the first one cut with colophon v0.4.0 or
later is also tagged lint/vX.Y.Z, so pin the same version you pin for
controls itself: @v0.6.0 runs the analyzer released with v0.6.0. @latest
resolves to the newest such tag, or to the tip of main while none exists.
The analyzer exits 3 on a finding, as go vet exits non-zero, and 1 when it
could not load the packages at all. go run hides that: it reports a
non-zero child as exit status 3 and exits 1 itself, so anything that keys on
the exit code should go install the command and run the binary. It is advisory by design: the cicd
component that runs it in the estate, go-singleuse, tolerates exit code 3
alone, so a finding shows as a warning and a broken run still fails. A job
you write yourself needs the same allow_failure: exit_codes: [3]. Advisory
because the analyzer cannot see whether a restart policy is attached, so a
capture that is safe today because nothing restarts it is flagged all the
same.
Read a finding¶
Each finding names the value, its type, and how the StartFunc reached it:
grpc/server.go:640:31: StartFunc closure uses startServer, built by a call that captures srv (*google.golang.org/grpc.Server); a restart reuses it
http/server.go:571:36: StartFunc is built by a call that captures srv (*net/http.Server); a restart reuses it
tables.go:97:60: Start reads receiver field sup (*gitlab.com/phpboyscout/go/controls.Supervisor) it did not build; a restart reuses it
Those three are real: the first two are go/transport's servers, the third is a
supervisor held in a field and started from a Start method. The shapes it
recognises:
| Finding says | The shape |
|---|---|
closure captures X |
the closure passed to WithStart or Child.Start names a single-use value from outside, including a package-level one |
closure reaches a.b through captured a |
it reaches one through a struct it captured, by field, by an embedded field's promoted method, or by value |
is the Start method of X |
a method value, WithStart(sup.Start), on a single-use value built at wiring |
f reaches X |
a named function handed to WithStart uses a package-level single-use value |
is built by a call that captures X |
the StartFunc came from a call whose argument is the value |
uses f, built by a call that captures X |
the StartFunc variable, declared with :=, var or a later =, was built by such a call |
Start reads receiver field X it did not build |
a Start(ctx) error method uses a field, at any depth, assigned elsewhere, usually the constructor |
An alias of a listed type, and a pointer to one, count as the type.
The single-use types are *grpc.Server, *http.Server, *controls.Supervisor,
net.Listener and *net.TCPListener.
Fix it¶
Build the thing inside the run. The second run then gets a second one:
controls.WithStart(func(ctx context.Context) error {
srv := grpc.NewServer() // per run, not at wiring
registerServices(srv)
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(lis)
})
When the value has to outlive the StartFunc, because a Stop or a Status
probe needs it too, wrap it in a Generational, which
builds one per generation and refuses access to a stopped one. That is what
go/messaging did for its supervisor, and the analyzer is silent on it because
the supervisor is built by Build, not read from a field a constructor filled.
For a Start method reading a field, either assign the field inside Start
(the analyzer treats a field the method assigns as built per run, and the same
holds for a variable a closure assigns) or move the construction into a
Generational Build. For WithStart(sup.Start) on a supervisor, wrap the
supervisor in a Generational if the service can be restarted; if it never
is, the finding is the advisory kind and says so.
What it cannot see¶
- A single-use value captured through an interface type of your own. The list
matches named types, so a
Serverinterface wrapping*http.Serveris invisible. - A
StartFuncbuilt two helper calls deep, or assigned from a multi-value call (start, err := build(srv)). One level and one value are followed. - A named function or
StartFuncvariable defined in another package. - A
Startmethod whose field is rebuilt by a helper it calls rather than by an assignment in its own body, or a closure that rebuilds the whole struct it captured. Those are the first false positives to expect.
The rule the analyzer enforces, and the two failures it prevents, are in What a restart shares. A type missing from its list is a merge request to this repository, with the capture that motivated it in the commit message.
Related¶
- Survive a restart: the recipe for a value that has
to outlive the
StartFunc. - The restart supervisor: why a restart shares the closure at all.