Skip to content

feat(helper): start gRPC server when API.EnableGRPC is true#21

Merged
kevinelliott merged 1 commit intomainfrom
feat/helper-enable-grpc
Apr 21, 2026
Merged

feat(helper): start gRPC server when API.EnableGRPC is true#21
kevinelliott merged 1 commit intomainfrom
feat/helper-enable-grpc

Conversation

@kevinelliott
Copy link
Copy Markdown
Owner

Summary

The systray helper previously only started the REST API when API.EnableREST was set; the matching API.EnableGRPC config 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.

  • Default remains OFF — no behavior change for existing users.
  • Port defaults to 50051 (per pkg/config/config.go).
  • Both REST and gRPC can run concurrently — independent if-blocks in Run() on distinct ports.

Changes

  • internal/systray/systray.go:
    • New grpcServer *grpcapi.Server field (internal grpc pkg aliased as grpcapi to avoid shadowing google.golang.org/grpc).
    • New startGRPCServer() mirroring startRESTServer(), using WithVersion(a.version) and WithStartTime(a.startTime).
    • Run() starts the gRPC server when a.config.API.EnableGRPC is true.
    • Shutdown path runs GracefulStop bounded by a 2s timeout, falling back to a hard ForceStop so a wedged streaming RPC cannot block helper exit.
  • pkg/api/grpc/server.go:
    • Add ForceStop() for the hard-stop fallback used by the systray shutdown path.
  • internal/systray/systray_test.go:
    • New TestStartGRPCServerPopulatesField verifies startGRPCServer() populates App.grpcServer and the listener binds (ephemeral :0 port for test isolation).
  • CHANGELOG.md[Unreleased] > Added entry.

Test plan

  • go build ./... clean on darwin
  • go test ./internal/systray/... ./pkg/api/grpc/... ./pkg/api/rest/... -race -count=1 green
  • /tmp/gcil-install/golangci-lint run --timeout=5m clean
  • Manual smoke: set api.enable_grpc: true in config, launch helper, run grpcurl -plaintext localhost:50051 list (reflection is registered by the server)
  • Manual smoke: leave default config, launch helper, confirm nothing binds to 50051 (default OFF preserved)

Diff stat: 4 files changed, 109 insertions(+).

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

Copilot AI review requested due to automatic review settings April 21, 2026 09:00
Comment thread pkg/api/grpc/server.go
s.grpcServer.Stop()
}
if s.listener != nil {
s.listener.Close()
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.EnableGRPC is 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.

Comment thread pkg/api/grpc/server.go
Comment on lines +200 to +206
// 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()
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +86
// 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()")
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +47
// 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.
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
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>
@kevinelliott kevinelliott force-pushed the feat/helper-enable-grpc branch from c5c6642 to 74222a4 Compare April 21, 2026 09:17
@kevinelliott kevinelliott merged commit 42350ad into main Apr 21, 2026
15 checks passed
kevinelliott added a commit that referenced this pull request Apr 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants