Skip to content

Commit 42350ad

Browse files
kevinelliottclaude
andauthored
feat(helper): start gRPC server when API.EnableGRPC is true (#21)
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>
1 parent c472227 commit 42350ad

4 files changed

Lines changed: 109 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Systray helper now starts the gRPC API server when `API.EnableGRPC=true`
13+
(default off, matching REST).
14+
1015
### Removed
1116

1217
- `config.CatalogConfig.RefreshOnStart` field removed. Deprecated in

internal/systray/systray.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/kevinelliott/agentmanager/internal/orchestrator"
1818
"github.com/kevinelliott/agentmanager/internal/versionfetch"
1919
"github.com/kevinelliott/agentmanager/pkg/agent"
20+
grpcapi "github.com/kevinelliott/agentmanager/pkg/api/grpc"
2021
"github.com/kevinelliott/agentmanager/pkg/api/rest"
2122
"github.com/kevinelliott/agentmanager/pkg/catalog"
2223
"github.com/kevinelliott/agentmanager/pkg/config"
@@ -56,6 +57,9 @@ type App struct {
5657
// REST API server (optional)
5758
restServer *rest.Server
5859

60+
// gRPC API server (optional)
61+
grpcServer *grpcapi.Server
62+
5963
// State
6064
agents []agent.Installation
6165
agentsMu sync.RWMutex
@@ -135,6 +139,13 @@ func (a *App) Run() error {
135139
}
136140
}
137141

142+
// Start gRPC API server if enabled
143+
if a.config.API.EnableGRPC {
144+
if err := a.startGRPCServer(); err != nil {
145+
return fmt.Errorf("failed to start gRPC server: %w", err)
146+
}
147+
}
148+
138149
// Run systray (blocks until quit)
139150
// On macOS, this must run on the main thread
140151
systray.Run(a.onReady, a.onExit)
@@ -158,6 +169,18 @@ func (a *App) startRESTServer() error {
158169
})
159170
}
160171

172+
// startGRPCServer starts the gRPC API server.
173+
func (a *App) startGRPCServer() error {
174+
a.grpcServer = grpcapi.NewServer(
175+
a.config, a.platform, a.store, a.detector, a.catalog, a.installer,
176+
grpcapi.WithVersion(a.version),
177+
grpcapi.WithStartTime(a.startTime),
178+
)
179+
return a.grpcServer.Start(a.ctx, grpcapi.ServerConfig{
180+
Address: fmt.Sprintf(":%d", a.config.API.GRPCPort),
181+
})
182+
}
183+
161184
// startIPCServer starts the IPC server for CLI communication.
162185
func (a *App) startIPCServer() error {
163186
a.ipcServer = ipc.NewServer("")
@@ -392,6 +415,24 @@ func (a *App) onExit() {
392415
a.restServer.Stop(ctx)
393416
}
394417

418+
// Stop gRPC server: prefer GracefulStop with a short timeout; fall back
419+
// to a hard Stop if graceful shutdown does not complete in time so a
420+
// wedged in-flight RPC cannot block helper exit.
421+
if a.grpcServer != nil {
422+
graceCtx, graceCancel := context.WithTimeout(context.Background(), 2*time.Second)
423+
gracefulDone := make(chan struct{})
424+
go func() {
425+
_ = a.grpcServer.Stop(graceCtx)
426+
close(gracefulDone)
427+
}()
428+
select {
429+
case <-gracefulDone:
430+
case <-graceCtx.Done():
431+
a.grpcServer.ForceStop()
432+
}
433+
graceCancel()
434+
}
435+
395436
// Stop IPC server (drains in-flight handlers before returning).
396437
if a.ipcServer != nil {
397438
a.ipcServer.Stop(ctx)

internal/systray/systray_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package systray
22

33
import (
44
"bytes"
5+
"context"
56
"testing"
7+
"time"
8+
9+
"github.com/kevinelliott/agentmanager/pkg/config"
610
)
711

812
func TestGetIcon(t *testing.T) {
@@ -35,3 +39,50 @@ func TestGetIconConsistency(t *testing.T) {
3539
t.Error("getIcon() should return consistent results")
3640
}
3741
}
42+
43+
// TestStartGRPCServerPopulatesField verifies that startGRPCServer wires up
44+
// the gRPC server on App when invoked (which is what Run() does when
45+
// API.EnableGRPC is true). Uses port :0 to avoid binding to a fixed port.
46+
// Also confirms that the server is nil if startGRPCServer is never called,
47+
// matching the EnableGRPC=false default path.
48+
func TestStartGRPCServerPopulatesField(t *testing.T) {
49+
ctx, cancel := context.WithCancel(context.Background())
50+
defer cancel()
51+
52+
a := &App{
53+
config: &config.Config{
54+
API: config.APIConfig{
55+
EnableGRPC: true,
56+
GRPCPort: 0, // bind ephemeral port for test isolation
57+
},
58+
},
59+
ctx: ctx,
60+
startTime: time.Now(),
61+
version: "test",
62+
}
63+
64+
// Default path: server should be nil when not started.
65+
if a.grpcServer != nil {
66+
t.Fatal("grpcServer should be nil before startGRPCServer() runs")
67+
}
68+
69+
if err := a.startGRPCServer(); err != nil {
70+
t.Fatalf("startGRPCServer() error = %v", err)
71+
}
72+
defer func() {
73+
if a.grpcServer != nil {
74+
_ = a.grpcServer.Stop(context.Background())
75+
}
76+
}()
77+
78+
if a.grpcServer == nil {
79+
t.Fatal("startGRPCServer() did not populate App.grpcServer")
80+
}
81+
82+
// Give the goroutine inside Start() a moment to register the listener,
83+
// then verify it has a bound address (proves Serve is actually running).
84+
time.Sleep(20 * time.Millisecond)
85+
if addr := a.grpcServer.Address(); addr == "" {
86+
t.Error("grpc server Address() should be non-empty after Start()")
87+
}
88+
}

pkg/api/grpc/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,18 @@ func (s *Server) Stop(ctx context.Context) error {
197197
return nil
198198
}
199199

200+
// ForceStop immediately stops the gRPC server without waiting for in-flight
201+
// RPCs to complete. Intended as a fallback when a bounded GracefulStop does
202+
// not return in time (e.g. a wedged streaming handler during helper exit).
203+
func (s *Server) ForceStop() {
204+
if s.grpcServer != nil {
205+
s.grpcServer.Stop()
206+
}
207+
if s.listener != nil {
208+
s.listener.Close()
209+
}
210+
}
211+
200212
// Address returns the server's listening address.
201213
func (s *Server) Address() string {
202214
if s.listener != nil {

0 commit comments

Comments
 (0)