Skip to content

Commit 0c4bfb4

Browse files
authored
Merge pull request #134 from thushan/chore/housekeeping-april-2026
chore: housekeeping April 2026
2 parents 7d153be + ec5d03f commit 0c4bfb4

10 files changed

Lines changed: 108 additions & 148 deletions

File tree

internal/adapter/health/checker.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ func (c *HTTPHealthChecker) StartChecking(ctx context.Context) error {
105105
}
106106

107107
func (c *HTTPHealthChecker) StopChecking(ctx context.Context) error {
108-
if !c.isRunning.Load() {
108+
// CAS from true→false is the single guard; concurrent callers that lose the
109+
// race see false and return early without touching stopCh.
110+
if !c.isRunning.CompareAndSwap(true, false) {
109111
return nil
110112
}
111113

@@ -114,7 +116,6 @@ func (c *HTTPHealthChecker) StopChecking(ctx context.Context) error {
114116
}
115117

116118
close(c.stopCh)
117-
c.isRunning.Store(false)
118119

119120
return nil
120121
}

internal/adapter/health/checker_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,34 @@ func (s *statusCodeHTTPClient) Do(req *http.Request) (*http.Response, error) {
714714
Body: http.NoBody,
715715
}, nil
716716
}
717+
718+
// TestStopChecking_DoubleInvoke verifies concurrent double-stops do not panic.
719+
// Previously, two callers that both passed the isRunning.Load() guard could
720+
// race to close(stopCh), causing a "close of closed channel" panic.
721+
func TestStopChecking_DoubleInvoke(t *testing.T) {
722+
t.Parallel()
723+
724+
loggerCfg := &logger.Config{Level: "error", Theme: "default"}
725+
log, cleanup, _ := logger.New(loggerCfg)
726+
defer cleanup()
727+
styledLogger := logger.NewPlainStyledLogger(log)
728+
729+
mockRepo := newMockRepository()
730+
checker := NewHTTPHealthChecker(mockRepo, styledLogger, &mockHTTPClient{statusCode: 200})
731+
732+
// Start the checker so isRunning == true.
733+
if err := checker.StartChecking(context.Background()); err != nil {
734+
t.Fatalf("StartChecking: %v", err)
735+
}
736+
737+
// Two concurrent stops — neither should panic.
738+
var wg sync.WaitGroup
739+
wg.Add(2)
740+
for range 2 {
741+
go func() {
742+
defer wg.Done()
743+
_ = checker.StopChecking(context.Background())
744+
}()
745+
}
746+
wg.Wait()
747+
}

internal/adapter/proxy/core/retry.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,16 +210,18 @@ func IsConnectionError(err error) bool {
210210
return hasConnectionError(err)
211211
}
212212

213+
// connectionErrors is a last-resort string fallback for errors that have lost
214+
// their type information (e.g. plain errors.New from middleware or test stubs).
215+
// Well-wrapped OS errors are already caught by the net.Error / syscall.Errno
216+
// branches above, so this list covers only cases those branches cannot reach.
213217
var connectionErrors = []string{
214-
"connection refused",
215-
"connection reset",
216-
"no such host",
217-
"network is unreachable",
218-
"no route to host",
219-
"connection timed out",
220-
"i/o timeout",
221-
"dial tcp",
222-
"connectex:",
218+
"connection refused", // syscall.ECONNREFUSED on non-unwrappable paths
219+
"connection reset", // syscall.ECONNRESET on non-unwrappable paths
220+
"no such host", // *net.DNSError without type chain
221+
"network is unreachable", // syscall.ENETUNREACH without type chain
222+
"no route to host", // syscall.EHOSTUNREACH without type chain
223+
"i/o timeout", // plain-string timeout errors; net.Error.Timeout() covers wrapped ones
224+
"connectex:", // Windows dial error prefix; appears without net.Error wrapping on some paths
223225
}
224226

225227
func hasConnectionError(err error) bool {

internal/adapter/proxy/olla/service.go

Lines changed: 29 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"net/url"
3232
"runtime"
3333
"runtime/debug"
34+
"sync"
3435
"sync/atomic"
3536
"time"
3637

@@ -66,22 +67,23 @@ type Service struct {
6667
*core.BaseProxyComponents
6768

6869
// Object pools for zero-allocation operations
69-
bufferPool *pool.Pool[*[]byte]
70-
requestPool *pool.Pool[*requestContext]
71-
responsePool *pool.Pool[[]byte]
72-
errorPool *pool.Pool[*errorContext]
70+
bufferPool *pool.Pool[*[]byte]
71+
requestPool *pool.Pool[*requestContext]
72+
errorPool *pool.Pool[*errorContext]
7373

7474
transport *http.Transport
7575
configuration *Configuration
7676
retryHandler *core.RetryHandler
7777

78-
// Cleanup management
7978
cleanupTicker *time.Ticker
8079
cleanupStop chan struct{}
8180

8281
// Per-endpoint connection pools and circuit breakers
8382
endpointPools xsync.Map[string, *connectionPool]
8483
circuitBreakers xsync.Map[string, *circuitBreaker]
84+
85+
// Cleanup management
86+
cleanupOnce sync.Once
8587
}
8688

8789
// connectionPool isolates HTTP transport instances per endpoint
@@ -177,13 +179,6 @@ func NewService(
177179
return nil, fmt.Errorf("failed to create request pool: %w", err)
178180
}
179181

180-
responsePool, err := pool.NewLitePool(func() []byte {
181-
return make([]byte, 32*1024) // 32KB for response bodies
182-
})
183-
if err != nil {
184-
return nil, fmt.Errorf("failed to create response pool: %w", err)
185-
}
186-
187182
errorPool, err := pool.NewLitePool(func() *errorContext {
188183
return &errorContext{}
189184
})
@@ -197,7 +192,6 @@ func NewService(
197192
BaseProxyComponents: base,
198193
bufferPool: bufferPool,
199194
requestPool: requestPool,
200-
responsePool: responsePool,
201195
errorPool: errorPool,
202196
transport: transport,
203197
configuration: configuration,
@@ -328,104 +322,6 @@ func (s *Service) ProxyRequestToEndpoints(ctx context.Context, w http.ResponseWr
328322
return s.ProxyRequestToEndpointsWithRetry(ctx, w, r, endpoints, stats, rlog)
329323
}
330324

331-
// proxyToSingleEndpointLegacy retained for reference during migration
332-
// TODO: Remove after retry logic stability confirmed
333-
func (s *Service) proxyToSingleEndpointLegacy(ctx context.Context, w http.ResponseWriter, r *http.Request, endpoints []*domain.Endpoint, stats *ports.RequestStats, rlog logger.StyledLogger) (err error) {
334-
// Get request context from pool
335-
reqCtx := s.requestPool.Get()
336-
defer s.requestPool.Put(reqCtx)
337-
338-
reqCtx.requestID = stats.RequestID
339-
reqCtx.startTime = stats.StartTime
340-
341-
// Panic recovery
342-
defer func() {
343-
if rec := recover(); rec != nil {
344-
s.handlePanic(ctx, w, r, stats, rlog, rec, &err)
345-
}
346-
}()
347-
348-
s.IncrementRequests()
349-
350-
// Use context logger if available, fallback to provided logger
351-
ctxLogger := middleware.GetLogger(ctx)
352-
if ctxLogger != nil {
353-
ctxLogger.Debug("Olla proxy request started",
354-
"method", r.Method,
355-
"url", r.URL.String(),
356-
"endpoint_count", len(endpoints))
357-
} else {
358-
rlog.Debug("proxy request started", "method", r.Method, "url", r.URL.String())
359-
}
360-
361-
if len(endpoints) == 0 {
362-
if ctxLogger != nil {
363-
ctxLogger.Error("No healthy endpoints available for request")
364-
} else {
365-
rlog.Error("no healthy endpoints available")
366-
}
367-
s.RecordFailure(ctx, nil, time.Since(stats.StartTime), common.ErrNoHealthyEndpoints)
368-
return common.ErrNoHealthyEndpoints
369-
}
370-
371-
if ctxLogger != nil {
372-
ctxLogger.Debug("Using provided endpoints", "count", len(endpoints))
373-
} else {
374-
rlog.Debug("using provided endpoints", "count", len(endpoints))
375-
}
376-
377-
// Select endpoint with circuit breaker check
378-
endpoint, cb := s.selectEndpointWithCircuitBreaker(endpoints, rlog)
379-
if endpoint == nil {
380-
s.RecordFailure(ctx, nil, time.Since(stats.StartTime), errors.New("all endpoints circuit breakers open"))
381-
return errors.New("all endpoints unavailable due to circuit breakers")
382-
}
383-
384-
stats.EndpointName = endpoint.Name
385-
reqCtx.endpoint = endpoint.Name
386-
387-
// Track connections
388-
s.Selector.IncrementConnections(endpoint)
389-
defer s.Selector.DecrementConnections(endpoint)
390-
391-
// Build target URL
392-
targetURL := s.buildTargetURL(r, endpoint)
393-
stats.TargetUrl = targetURL.String()
394-
reqCtx.targetURL = targetURL.String()
395-
396-
if ctxLogger != nil {
397-
ctxLogger.Info("Request dispatching",
398-
"endpoint", endpoint.Name,
399-
"target", stats.TargetUrl,
400-
"model", stats.Model)
401-
} else {
402-
rlog.Info("Request dispatching", "endpoint", endpoint.Name, "target", stats.TargetUrl, "model", stats.Model)
403-
}
404-
405-
// Create and prepare proxy request
406-
// Rewrite model name in request body if this is an alias-resolved request
407-
core.RewriteModelForAlias(ctx, r, endpoint)
408-
409-
proxyReq, err := s.prepareProxyRequest(ctx, r, targetURL, stats)
410-
if err != nil {
411-
cb.RecordFailure()
412-
s.RecordFailure(ctx, endpoint, time.Since(stats.StartTime), err)
413-
return fmt.Errorf("failed to create proxy request: %w", err)
414-
}
415-
416-
rlog.Debug("created proxy request")
417-
418-
// Execute backend request
419-
resp, err := s.executeBackendRequest(ctx, endpoint, proxyReq, cb, stats, rlog)
420-
if err != nil {
421-
return err
422-
}
423-
defer resp.Body.Close()
424-
425-
// Handle successful response
426-
return s.handleSuccessfulResponse(ctx, w, r, resp, endpoint, cb, stats, rlog)
427-
}
428-
429325
// handlePanic handles panic recovery in proxy requests
430326
func (s *Service) handlePanic(ctx context.Context, w http.ResponseWriter, r *http.Request, stats *ports.RequestStats, rlog logger.StyledLogger, rec interface{}, err *error) {
431327
s.RecordFailure(ctx, nil, time.Since(stats.StartTime), fmt.Errorf("panic: %v", rec))
@@ -483,7 +379,7 @@ func (s *Service) prepareProxyRequest(ctx context.Context, r *http.Request, targ
483379
stats.HeaderProcessingMs = time.Since(headerStart).Milliseconds()
484380

485381
// Add model header
486-
if model, ok := ctx.Value("model").(string); ok && model != "" {
382+
if model, ok := ctx.Value(constants.ContextModelKey).(string); ok && model != "" {
487383
proxyReq.Header.Set("X-Model", model)
488384
stats.Model = model
489385
}
@@ -777,29 +673,31 @@ func (s *Service) cleanupUnusedResources() {
777673
}
778674
}
779675

780-
// Cleanup cleans up resources
676+
// Cleanup cleans up resources. Safe to call more than once.
781677
func (s *Service) Cleanup() {
782-
// Stop cleanup goroutine
783-
if s.cleanupStop != nil {
784-
close(s.cleanupStop)
785-
}
786-
if s.cleanupTicker != nil {
787-
s.cleanupTicker.Stop()
788-
}
678+
s.cleanupOnce.Do(func() {
679+
// Stop cleanup goroutine
680+
if s.cleanupStop != nil {
681+
close(s.cleanupStop)
682+
}
683+
if s.cleanupTicker != nil {
684+
s.cleanupTicker.Stop()
685+
}
789686

790-
// Close all endpoint pools
791-
s.endpointPools.Range(func(key string, pool *connectionPool) bool {
792-
pool.transport.CloseIdleConnections()
793-
return true
794-
})
687+
// Close all endpoint pools
688+
s.endpointPools.Range(func(key string, pool *connectionPool) bool {
689+
pool.transport.CloseIdleConnections()
690+
return true
691+
})
795692

796-
s.endpointPools.Clear()
797-
s.circuitBreakers.Clear()
693+
s.endpointPools.Clear()
694+
s.circuitBreakers.Clear()
798695

799-
s.BaseProxyComponents.Shutdown()
696+
s.BaseProxyComponents.Shutdown()
800697

801-
// force GC to clean up
802-
runtime.GC()
698+
// force GC to clean up
699+
runtime.GC()
803700

804-
s.Logger.Debug("Olla proxy service cleaned up")
701+
s.Logger.Debug("Olla proxy service cleaned up")
702+
})
805703
}

internal/adapter/proxy/olla/service_leak_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,29 @@ func (m *mockStatsCollector) GetEndpointStats() map[string]ports.EndpointStats {
367367
func (m *mockStatsCollector) GetSecurityStats() ports.SecurityStats { return ports.SecurityStats{} }
368368
func (m *mockStatsCollector) GetConnectionStats() map[string]int64 { return nil }
369369

370+
// TestCleanup_DoubleInvoke verifies that calling Cleanup twice does not panic.
371+
// Previously, the second call would close an already-closed channel.
372+
func TestCleanup_DoubleInvoke(t *testing.T) {
373+
t.Parallel()
374+
375+
s := &Service{
376+
BaseProxyComponents: &core.BaseProxyComponents{
377+
Logger: createTestLogger(),
378+
},
379+
configuration: &Configuration{},
380+
endpointPools: *xsync.NewMap[string, *connectionPool](),
381+
circuitBreakers: *xsync.NewMap[string, *circuitBreaker](),
382+
cleanupTicker: time.NewTicker(time.Hour),
383+
cleanupStop: make(chan struct{}),
384+
}
385+
386+
go s.cleanupLoop()
387+
388+
// Neither call should panic.
389+
s.Cleanup()
390+
s.Cleanup()
391+
}
392+
370393
func createTestLogger() logger.StyledLogger {
371394
loggerCfg := &logger.Config{Level: "error", Theme: "default"}
372395
log, _, _ := logger.New(loggerCfg)

internal/adapter/proxy/proxy_headers_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func TestProxyResponseHeaders(t *testing.T) {
8383
// Test with model in context
8484
t.Run("with model", func(t *testing.T) {
8585
req := httptest.NewRequest("GET", "/test", nil)
86-
ctx := context.WithValue(req.Context(), "model", "llama3.2:3b")
86+
ctx := context.WithValue(req.Context(), constants.ContextModelKey, "llama3.2:3b")
8787
req = req.WithContext(ctx)
8888

8989
w := httptest.NewRecorder()
@@ -127,7 +127,7 @@ func TestProxyResponseHeaders_NoOverride(t *testing.T) {
127127
proxy, _ := sherpa.NewService(discovery, selector, config, createTestStatsCollector(), nil, createTestLogger())
128128

129129
req := httptest.NewRequest("GET", "/test", nil)
130-
ctx := context.WithValue(req.Context(), "model", "real-model")
130+
ctx := context.WithValue(req.Context(), constants.ContextModelKey, "real-model")
131131
req = req.WithContext(ctx)
132132

133133
w := httptest.NewRecorder()

internal/adapter/proxy/sherpa/service_retry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (s *Service) proxyToSingleEndpoint(ctx context.Context, w http.ResponseWrit
8686
stats.HeaderProcessingMs = time.Since(headerStart).Milliseconds()
8787

8888
// Add model header if available
89-
if model, ok := ctx.Value("model").(string); ok && model != "" {
89+
if model, ok := ctx.Value(constants.ContextModelKey).(string); ok && model != "" {
9090
proxyReq.Header.Set(constants.HeaderXModel, model)
9191
stats.Model = model
9292
}

internal/app/handlers/handler_translation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ func (a *Application) executeTranslatedNonStreamingRequest(
349349
// prepareProxyContext sets up context with model, routing decision, and alias rewrite map
350350
func (a *Application) prepareProxyContext(ctx context.Context, r *http.Request, pr *proxyRequest) (context.Context, *http.Request) {
351351
if pr.model != "" {
352-
ctx = context.WithValue(ctx, "model", pr.model)
352+
ctx = context.WithValue(ctx, constants.ContextModelKey, pr.model)
353353
r = r.WithContext(ctx)
354354
}
355355

internal/app/handlers/handler_translation_alias_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func TestPrepareProxyContext_NoAliasMapWhenNoneStored(t *testing.T) {
7777
assert.Nil(t, rawMap, "alias rewrite map should not be present for non-alias requests")
7878

7979
// Model should still be set in context
80-
assert.Equal(t, "llama3.1:8b", r.Context().Value("model"))
80+
assert.Equal(t, "llama3.1:8b", r.Context().Value(constants.ContextModelKey))
8181
}
8282

8383
func TestPrepareProxyContext_NilProfile(t *testing.T) {
@@ -100,5 +100,5 @@ func TestPrepareProxyContext_NilProfile(t *testing.T) {
100100
rawMap := r.Context().Value(constants.ContextModelAliasMapKey)
101101
assert.Nil(t, rawMap, "alias rewrite map should not be present when profile is nil")
102102

103-
assert.Equal(t, "llama3.1:8b", r.Context().Value("model"))
103+
assert.Equal(t, "llama3.1:8b", r.Context().Value(constants.ContextModelKey))
104104
}

internal/core/constants/context.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const (
1111
ContextKeyStream = "stream" // indicates whether the response should be streamed or buffered
1212
ContextProviderTypeKey = "provider_type" // the provider type for the request, used for routing and load balancing
1313

14+
// ContextModelKey carries the resolved model name through the proxy pipeline.
15+
// Using a typed key prevents accidental collisions with plain-string keys from
16+
// third-party middleware that might also use "model".
17+
ContextModelKey = contextKey("model")
18+
1419
// Sticky session context keys — set by the handler before balancer selection
1520
// and read back after to surface affinity decisions in response headers.
1621
ContextStickyKeyKey = contextKey("sticky-key") // computed affinity key for this request

0 commit comments

Comments
 (0)