项目文件夹

文件
hippoley cfd9c46ca8 fix: add graceful shutdown, fix WebSocket Hub race, plug goroutine leaks
Address three HIGH-severity issues from the memory-leak and bloat
analysis (issue #56).

1. Graceful shutdown (main.go)
   - Replace gin's r.Run() with an explicit http.Server so the process
     can intercept SIGINT / SIGTERM.
   - On signal: drain active HTTP requests (10 s timeout), then stop
     SyncService, WebSocket Hub, and InstanceAccessService cleanup
     goroutine in order.
   - Ensures database connections, K8s watchers, and background loops
     are released cleanly on deploy or restart.

2. WebSocket Hub init race (websocket_service.go)
   - GetHub() used a bare nil-check with no synchronisation; two
     goroutines could each create a Hub and start a Run() loop.
   - Replaced with sync.Once to guarantee exactly one Hub instance.
   - Added a stop channel to Hub.Run() so the hub can be shut down
     gracefully, closing all connected clients.

3. InstanceAccessService goroutine leak (instance_access_service.go)
   - cleanupExpiredTokens() looped on ticker.C with no exit path,
     leaking the goroutine for the lifetime of the process.
   - Added a stopChan; cleanupExpiredTokens now selects on both the
     ticker and the stop signal.
   - Exposed Stop() on the service; InstanceHandler.Shutdown() calls
     it during graceful shutdown.

Tests:
- TestGetHubReturnsSameInstance: singleton guarantee
- TestGetHubConcurrentAccess: 50-goroutine race test
- TestHubStopClosesClients: verifies client cleanup on Stop()
- TestInstanceAccessServiceStopTerminatesCleanup: Stop() is safe and
  the service remains functional for token ops afterward

All existing tests continue to pass; full project build and regression
verified.

Ref: #56
2026-04-24 20:07:13 +08:00

92 行
2.7 KiB
Go

package services
import (
"testing"
"time"
)
func TestInstanceAccessServiceValidatesTokenAcrossServiceInstances(t *testing.T) {
t.Setenv("INSTANCE_ACCESS_TOKEN_SECRET", "cluster-shared-secret")
issuer := NewInstanceAccessService()
validator := NewInstanceAccessService()
token, err := issuer.GenerateToken(7, 42, "openclaw", "/api/v1/instances/42/proxy/", 3001, 5*time.Minute)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
validated, err := validator.ValidateToken(token.Token)
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
if validated.InstanceID != 42 {
t.Fatalf("validated.InstanceID = %d, want 42", validated.InstanceID)
}
if validated.UserID != 7 {
t.Fatalf("validated.UserID = %d, want 7", validated.UserID)
}
if validated.InstanceType != "openclaw" {
t.Fatalf("validated.InstanceType = %q, want openclaw", validated.InstanceType)
}
}
func TestInstanceAccessServiceRejectsExpiredSignedToken(t *testing.T) {
t.Setenv("INSTANCE_ACCESS_TOKEN_SECRET", "cluster-shared-secret")
service := NewInstanceAccessService()
token, err := service.GenerateToken(7, 42, "openclaw", "/api/v1/instances/42/proxy/", 3001, -time.Second)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if _, err := service.ValidateToken(token.Token); err == nil || err.Error() != "token expired" {
t.Fatalf("ValidateToken() error = %v, want token expired", err)
}
}
func TestInstanceAccessServiceFallsBackToLegacyTokens(t *testing.T) {
t.Setenv("INSTANCE_ACCESS_TOKEN_SECRET", "cluster-shared-secret")
service := NewInstanceAccessService()
service.tokens["legacy-token"] = &AccessToken{
Token: "legacy-token",
InstanceID: 11,
UserID: 3,
InstanceType: "ubuntu",
TargetPort: 3001,
AccessURL: "/api/v1/instances/11/proxy/",
ExpiresAt: time.Now().Add(time.Minute),
CreatedAt: time.Now(),
}
validated, err := service.ValidateToken("legacy-token")
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
if validated.InstanceID != 11 {
t.Fatalf("validated.InstanceID = %d, want 11", validated.InstanceID)
}
}
func TestInstanceAccessServiceStopTerminatesCleanup(t *testing.T) {
t.Setenv("INSTANCE_ACCESS_TOKEN_SECRET", "cluster-shared-secret")
service := NewInstanceAccessService()
// Stop should not panic and should be idempotent-safe (only called once).
service.Stop()
// After Stop, the service should still be usable for token operations
// (only the background cleanup goroutine is stopped).
token, err := service.GenerateToken(1, 1, "openclaw", "/proxy", 3001, time.Minute)
if err != nil {
t.Fatalf("GenerateToken after Stop() error = %v", err)
}
if _, err := service.ValidateToken(token.Token); err != nil {
t.Fatalf("ValidateToken after Stop() error = %v", err)
}
}