fix: prevent MCP server hanging by adding concurrency, timeouts, and context propagation (#45)

Root cause: synchronous MCP request processing combined with missing
context propagation to the embedding layer caused indefinite hangs when
ONNX inference was slow or the database was contended.

Changes:
- MCP server: dispatch each request in its own goroutine with semaphore
  (cap 10) and WaitGroup for clean shutdown drain
- Embedding: add context-aware mutex acquisition (acquireMutex) so
  callers can bail out instead of blocking forever on a stuck ONNX model
- Vector client: propagate context through getOrComputeEmbedding and
  replace bare RLock() calls with context-aware acquireRLockWithContext
- Worker handlers: add 15s request-scoped timeouts to all search/context
  handlers (handleSearchByPrompt, handleContextInject, handleFileContext,
  handleContextCount, handleGetObservations/Summaries/Prompts)
- Worker HTTP server: set WriteTimeout=60s (was 0); SSE endpoint extends
  deadline per-request via http.ResponseController

Fixes #45
This commit is contained in:
2026-05-26 14:29:34 +01:00
parent 56616d0616
commit 29d57857ff
7 changed files with 244 additions and 40 deletions
+9
View File
@@ -2,6 +2,7 @@
package embedding
import (
"context"
"fmt"
"sync"
)
@@ -44,6 +45,14 @@ type EmbeddingModel interface {
// EmbedBatch generates embeddings for multiple texts.
EmbedBatch(texts []string) ([][]float32, error)
// EmbedWithContext generates an embedding for a single text with context-aware cancellation.
// The context controls mutex acquisition timeout — if ctx is cancelled while waiting
// for the model lock, the call returns immediately with ctx.Err().
EmbedWithContext(ctx context.Context, text string) ([]float32, error)
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware cancellation.
EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error)
// Close releases model resources.
Close() error
}
+109
View File
@@ -3,6 +3,7 @@ package embedding
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -225,6 +226,103 @@ func (m *bgeModel) EmbedBatch(texts []string) ([][]float32, error) {
return results, nil
}
// acquireMutex attempts to acquire the model mutex, respecting context cancellation.
// On success the caller MUST call the returned unlock function.
// If ctx is cancelled while waiting, returns ctx.Err() and no unlock is needed.
func (m *bgeModel) acquireMutex(ctx context.Context) (unlock func(), err error) {
acquired := make(chan struct{})
go func() {
m.mu.Lock()
close(acquired)
}()
select {
case <-acquired:
// Got the lock normally.
return m.mu.Unlock, nil
case <-ctx.Done():
// Context cancelled while waiting. The goroutine above will eventually
// acquire the mutex — we must ensure it gets unlocked.
go func() {
<-acquired
m.mu.Unlock()
}()
return nil, ctx.Err()
}
}
// EmbedWithContext generates an embedding for a single text with context-aware mutex acquisition.
// If ctx is cancelled while waiting for the model lock, returns immediately with ctx.Err().
func (m *bgeModel) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
if text == "" {
return make([]float32, EmbeddingDim), nil
}
unlock, err := m.acquireMutex(ctx)
if err != nil {
return nil, fmt.Errorf("acquire embedding lock: %w", err)
}
defer unlock()
results, err := m.computeBatch([]string{text})
if err != nil {
return nil, err
}
if len(results) == 0 {
return make([]float32, EmbeddingDim), nil
}
return results[0], nil
}
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware mutex acquisition.
func (m *bgeModel) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
// Filter out empty texts and track indices
nonEmpty := make([]string, 0, len(texts))
indices := make([]int, 0, len(texts))
for i, t := range texts {
if t != "" {
nonEmpty = append(nonEmpty, t)
indices = append(indices, i)
}
}
// If all texts are empty, return zero vectors
if len(nonEmpty) == 0 {
results := make([][]float32, len(texts))
for i := range results {
results[i] = make([]float32, EmbeddingDim)
}
return results, nil
}
unlock, err := m.acquireMutex(ctx)
if err != nil {
return nil, fmt.Errorf("acquire embedding lock: %w", err)
}
defer unlock()
// Compute embeddings for non-empty texts
embeddings, err := m.computeBatch(nonEmpty)
if err != nil {
return nil, fmt.Errorf("compute batch embeddings: %w", err)
}
// Build result with zero vectors for empty texts
results := make([][]float32, len(texts))
for i := range results {
results[i] = make([]float32, EmbeddingDim)
}
for i, idx := range indices {
results[idx] = embeddings[i]
}
return results, nil
}
// computeBatch runs inference on a batch of texts. Must be called with lock held.
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
if len(sentences) == 0 {
@@ -509,6 +607,17 @@ func (s *Service) EmbedBatch(texts []string) ([][]float32, error) {
return s.model.EmbedBatch(texts)
}
// EmbedWithContext generates an embedding with context-aware cancellation.
// If ctx is cancelled while waiting for the model lock, returns immediately.
func (s *Service) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
return s.model.EmbedWithContext(ctx, text)
}
// EmbedBatchWithContext generates embeddings with context-aware cancellation.
func (s *Service) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
return s.model.EmbedBatchWithContext(ctx, texts)
}
// Close releases model resources.
func (s *Service) Close() error {
return s.model.Close()