fix: address 15 additional hang vectors found during deep audit (#45)

MCP server (5 fixes):
- Move semaphore acquisition inside goroutine so main loop stays
  responsive when all slots are taken
- Add 10s write timeout to sendResponse to prevent pipe deadlock
  when Claude Code pauses reading stdout
- Send fallback JSON-RPC error when json.Marshal fails instead of
  silently swallowing the error and leaving caller waiting forever
- Silence unknown notification methods (req.ID == nil) instead of
  sending unsolicited error responses that may desync the host
- Return MCP isError content for tool failures instead of top-level
  JSON-RPC error, matching the MCP specification

Vector/embedding (3 fixes):
- Move EmbedBatchWithContext call before writeMu.Lock in AddDocuments
  so ONNX inference runs outside the write lock
- Replace singleflight.Do with DoChan + ctx select in both
  getOrComputeEmbedding and UnifiedSearch so callers can bail out
  independently when their context expires
- Add activeQueries atomic counter; skip cache warming when user
  queries are in-flight; reduce warming timeout from 5s to 2s

Hooks (4 fixes):
- Cap EnsureWorkerRunning to 15s hard deadline with context; reduce
  StartupTimeout from 30s to 10s; reduce port-in-use retries
- Fix nil dereference panic in user-prompt hook when initResult is
  nil (non-JSON worker response); use comma-ok assertions
- Use package-level hookClient/healthClient with DisableKeepAlives
  to prevent FD leaks in short-lived hook processes
- Set SysProcAttr{Setpgid: true} to detach worker from hook process
  group, preventing kill-cascade from Claude Code

Worker/DB (3 fixes):
- Replace os.Exit(0) in MCP config watcher with context cancellation
  for clean protocol shutdown
- Add 60s context.WithTimeout around ProcessObservation calls in
  processAllSessions to prevent hung CLI subprocesses from blocking
  the queue processor forever
- Set explicit PRAGMA wal_autocheckpoint=1000 and add PASSIVE WAL
  checkpoint to Optimize() to prevent checkpoint stalls

Adds 20+ regression tests across all fix areas.
This commit is contained in:
2026-05-26 14:29:34 +01:00
parent de5796bbe6
commit a81482d06a
15 changed files with 952 additions and 92 deletions
+103 -2
View File
@@ -2,6 +2,7 @@
package hooks
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -518,7 +519,8 @@ func TestProjectIDWithName_Uniqueness(t *testing.T) {
func TestHookConstants(t *testing.T) {
assert.Equal(t, 37777, DefaultWorkerPort)
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
assert.Equal(t, 30*time.Second, StartupTimeout)
assert.Equal(t, 10*time.Second, StartupTimeout)
assert.Equal(t, 15*time.Second, EnsureWorkerDeadline)
}
// TestExitCodes tests exit code constants.
@@ -1200,5 +1202,104 @@ func TestHealthCheckTimeout(t *testing.T) {
// TestStartupTimeout tests the startup timeout is reasonable.
func TestStartupTimeout(t *testing.T) {
assert.Greater(t, StartupTimeout, 5*time.Second)
assert.LessOrEqual(t, StartupTimeout, time.Minute)
assert.LessOrEqual(t, StartupTimeout, 15*time.Second)
}
// TestEnsureWorkerDeadline tests the deadline is within hook budget.
func TestEnsureWorkerDeadline(t *testing.T) {
assert.Greater(t, EnsureWorkerDeadline, StartupTimeout, "deadline must exceed startup timeout")
assert.LessOrEqual(t, EnsureWorkerDeadline, 20*time.Second, "deadline must fit in hook timeout budget")
}
// --- Regression tests for Fixes 1, 3, 4 ---
// TestEnsureWorkerRunning_RespectsDeadline verifies that EnsureWorkerRunning returns
// within a bounded time even in worst case (no worker, startup fails).
func TestEnsureWorkerRunning_RespectsDeadline(t *testing.T) {
// Reset circuit breaker so the function actually tries to start.
circuitBreakerMu.Lock()
lastStartupFailure = time.Time{}
circuitBreakerMu.Unlock()
// Use a port that nothing listens on.
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "19999")
// Point HOME to a temp dir so findWorkerBinary finds nothing.
t.Setenv("HOME", t.TempDir())
// Clear plugin root to avoid that path too.
t.Setenv("CLAUDE_PLUGIN_ROOT", "")
start := time.Now()
_, err := EnsureWorkerRunning()
elapsed := time.Since(start)
// Must error (no binary found).
assert.Error(t, err)
assert.Contains(t, err.Error(), "worker binary not found")
// Must complete well within EnsureWorkerDeadline.
assert.Less(t, elapsed, EnsureWorkerDeadline,
"EnsureWorkerRunning took %v, exceeding deadline %v", elapsed, EnsureWorkerDeadline)
}
// TestEnsureWorkerRunningCtx_CancelledContext verifies immediate return on cancelled context.
func TestEnsureWorkerRunningCtx_CancelledContext(t *testing.T) {
// Reset circuit breaker.
circuitBreakerMu.Lock()
lastStartupFailure = time.Time{}
circuitBreakerMu.Unlock()
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "19998")
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
start := time.Now()
_, err := ensureWorkerRunningCtx(ctx)
elapsed := time.Since(start)
assert.Error(t, err)
assert.Less(t, elapsed, 1*time.Second, "cancelled context should return immediately")
}
// TestSleepCtx_Normal verifies sleepCtx completes normally.
func TestSleepCtx_Normal(t *testing.T) {
ctx := context.Background()
start := time.Now()
err := sleepCtx(ctx, 50*time.Millisecond)
elapsed := time.Since(start)
assert.NoError(t, err)
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond)
}
// TestSleepCtx_Cancelled verifies sleepCtx returns early on cancel.
func TestSleepCtx_Cancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
err := sleepCtx(ctx, 5*time.Second)
elapsed := time.Since(start)
assert.Error(t, err)
assert.Less(t, elapsed, 500*time.Millisecond)
}
// TestHookClients_DisableKeepAlives asserts shared clients disable keep-alives
// to prevent TIME_WAIT connection leaks in short-lived hook processes.
func TestHookClients_DisableKeepAlives(t *testing.T) {
hTransport, ok := hookClient.Transport.(*http.Transport)
require.True(t, ok, "hookClient.Transport should be *http.Transport")
assert.True(t, hTransport.DisableKeepAlives, "hookClient must disable keep-alives")
assert.Equal(t, 1, hTransport.MaxIdleConns)
hcTransport, ok := healthClient.Transport.(*http.Transport)
require.True(t, ok, "healthClient.Transport should be *http.Transport")
assert.True(t, hcTransport.DisableKeepAlives, "healthClient must disable keep-alives")
assert.Equal(t, 1, hcTransport.MaxIdleConns)
}
// TestHookClient_Timeout verifies hookClient timeout is set.
func TestHookClient_Timeout(t *testing.T) {
assert.Equal(t, 10*time.Second, hookClient.Timeout)
assert.Equal(t, HealthCheckTimeout, healthClient.Timeout)
}