feat(helper): start gRPC server when API.EnableGRPC is true#21
feat(helper): start gRPC server when API.EnableGRPC is true#21kevinelliott merged 1 commit intomainfrom
Conversation
| s.grpcServer.Stop() | ||
| } | ||
| if s.listener != nil { | ||
| s.listener.Close() |
There was a problem hiding this comment.
Pull request overview
This PR wires the existing gRPC API server into the systray helper lifecycle so API.EnableGRPC behaves as expected (start on run, stop on exit), mirroring the existing REST pattern.
Changes:
- Start the gRPC server from the systray helper when
API.EnableGRPCis enabled, and shut it down on helper exit with a graceful-then-force fallback. - Add
ForceStop()to the gRPC server implementation to support hard-stop shutdown semantics. - Add a systray-focused unit test for
startGRPCServer()and document the behavior in the changelog.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
internal/systray/systray.go |
Starts gRPC server when enabled; adds bounded graceful shutdown with hard-stop fallback. |
pkg/api/grpc/server.go |
Adds ForceStop() to allow immediate stop/cleanup of gRPC server resources. |
internal/systray/systray_test.go |
Adds a unit test validating startGRPCServer() populates the app field and binds a listener. |
CHANGELOG.md |
Notes the new systray behavior under [Unreleased]. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // ForceStop immediately stops the gRPC server without waiting for in-flight | ||
| // RPCs to complete. Intended as a fallback when a bounded GracefulStop does | ||
| // not return in time (e.g. a wedged streaming handler during helper exit). | ||
| func (s *Server) ForceStop() { | ||
| if s.grpcServer != nil { | ||
| s.grpcServer.Stop() | ||
| } |
There was a problem hiding this comment.
Now that ForceStop() exists, consider updating Stop(ctx) to actually honor the provided context (e.g., run GracefulStop in a goroutine and invoke ForceStop when ctx.Done() fires). As-is, Stop(ctx) ignores ctx and can block indefinitely, which forces callers (like systray shutdown) to implement their own timeout + fallback logic and makes the API contract misleading compared to REST Stop(ctx).
| // Give the goroutine inside Start() a moment to register the listener, | ||
| // then verify it has a bound address (proves Serve is actually running). | ||
| time.Sleep(20 * time.Millisecond) | ||
| if addr := a.grpcServer.Address(); addr == "" { | ||
| t.Error("grpc server Address() should be non-empty after Start()") |
There was a problem hiding this comment.
This test uses a fixed sleep to wait for the server goroutine to start, which can be flaky under load/slow CI. Prefer polling Address() with a short deadline (or exposing a readiness signal from Start) so the test deterministically waits until the listener is bound.
| // Give the goroutine inside Start() a moment to register the listener, | |
| // then verify it has a bound address (proves Serve is actually running). | |
| time.Sleep(20 * time.Millisecond) | |
| if addr := a.grpcServer.Address(); addr == "" { | |
| t.Error("grpc server Address() should be non-empty after Start()") | |
| // Poll until the listener is bound instead of relying on a fixed sleep, | |
| // which can be flaky under load or in slow CI environments. | |
| deadline := time.Now().Add(500 * time.Millisecond) | |
| for { | |
| if addr := a.grpcServer.Address(); addr != "" { | |
| break | |
| } | |
| if time.Now().After(deadline) { | |
| t.Error("grpc server Address() should be non-empty after Start()") | |
| break | |
| } | |
| time.Sleep(10 * time.Millisecond) |
| // TestStartGRPCServerPopulatesField verifies that startGRPCServer wires up | ||
| // the gRPC server on App when invoked (which is what Run() does when | ||
| // API.EnableGRPC is true). Uses port :0 to avoid binding to a fixed port. | ||
| // Also confirms that the server is nil if startGRPCServer is never called, | ||
| // matching the EnableGRPC=false default path. |
There was a problem hiding this comment.
The doc comment says this also confirms the EnableGRPC=false default path, but the test config explicitly sets EnableGRPC=true and doesn't exercise Run()'s branching. Consider rewording to just describe what the test actually asserts (grpcServer is nil before calling startGRPCServer, and non-nil afterward).
| // TestStartGRPCServerPopulatesField verifies that startGRPCServer wires up | |
| // the gRPC server on App when invoked (which is what Run() does when | |
| // API.EnableGRPC is true). Uses port :0 to avoid binding to a fixed port. | |
| // Also confirms that the server is nil if startGRPCServer is never called, | |
| // matching the EnableGRPC=false default path. | |
| // TestStartGRPCServerPopulatesField verifies that grpcServer is nil before | |
| // startGRPCServer is called and populated afterward. Uses port :0 to avoid | |
| // binding to a fixed port. |
The systray helper previously only started the REST API (when API.EnableREST was set); the matching API.EnableGRPC config flag was silently ignored. Mirror the REST startup pattern so operators who enable gRPC actually get a listener. Default remains OFF (no behavior change for existing users). Port defaults to 50051. REST and gRPC can run concurrently. - internal/systray: new grpcServer field, startGRPCServer(), Run() branch, and bounded-GracefulStop-with-ForceStop-fallback on exit. - pkg/api/grpc: add ForceStop() for the hard-stop fallback path used during helper shutdown when a streaming RPC wedges. - test: assert startGRPCServer populates App.grpcServer and binds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c5c6642 to
74222a4
Compare
Promote the Unreleased section to 1.2.0 and capture the UX / operability work landed since v1.1.0: - #20 install/update progress streaming with -v - #21 gRPC server wire-up in helper - #22, #26 test coverage (cli/output 0→82%, systray 0.2→3.2%) - #24 RefreshOnStart field removal - #27 bubbles v0.21 -> v1.0 - #28 go:embed baseline catalog (fresh `go install` works offline) - #29 pkg/logging slog wrapper wired into main and grpc recovery Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
The systray helper previously only started the REST API when
API.EnableRESTwas set; the matchingAPI.EnableGRPCconfig flag was silently ignored. This PR wires the gRPC server (already hardened in v1.1.0 with keepalive, message-size limits, and panic-recovery interceptors) into the helper startup/shutdown path, mirroring the REST pattern.pkg/config/config.go).Run()on distinct ports.Changes
internal/systray/systray.go:grpcServer *grpcapi.Serverfield (internal grpc pkg aliased asgrpcapito avoid shadowinggoogle.golang.org/grpc).startGRPCServer()mirroringstartRESTServer(), usingWithVersion(a.version)andWithStartTime(a.startTime).Run()starts the gRPC server whena.config.API.EnableGRPCis true.GracefulStopbounded by a 2s timeout, falling back to a hardForceStopso a wedged streaming RPC cannot block helper exit.pkg/api/grpc/server.go:ForceStop()for the hard-stop fallback used by the systray shutdown path.internal/systray/systray_test.go:TestStartGRPCServerPopulatesFieldverifiesstartGRPCServer()populatesApp.grpcServerand the listener binds (ephemeral:0port for test isolation).CHANGELOG.md—[Unreleased] > Addedentry.Test plan
go build ./...clean on darwingo test ./internal/systray/... ./pkg/api/grpc/... ./pkg/api/rest/... -race -count=1green/tmp/gcil-install/golangci-lint run --timeout=5mcleanapi.enable_grpc: truein config, launch helper, rungrpcurl -plaintext localhost:50051 list(reflection is registered by the server)Diff stat: 4 files changed, 109 insertions(+).
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com