Skip to content

Commit 8560ddc

Browse files
committed
fix(telemetry): join heartbeat loop before shutdown-marker resolve (Spec 080, review round 6)
1 parent f7a9d03 commit 8560ddc

4 files changed

Lines changed: 385 additions & 0 deletions

File tree

internal/runtime/prechurn_lifecycle_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,67 @@ func TestActivityServiceStopSafeWhenNeverStartedAndIdempotent(t *testing.T) {
241241
rt.ActivityService().Stop() // and a third
242242
}
243243

244+
// TestRuntimeCloseWaitsForTelemetryLoopBeforeMarkerResolve guards the Close
245+
// ordering added in review round 6 (Spec 080 FR-010): the telemetry heartbeat
246+
// loop is a BBolt writer too — v7's buildHeartbeat records funnel activity
247+
// (funnelStore.RecordActivity) — and before this fix Close only
248+
// context-cancelled the goroutine launched by lifecycle.go
249+
// (`go telemetryService.Start(appCtx)`) without joining it, so an in-flight
250+
// tick could write after the marker resolved to clean or against a closed DB.
251+
// Close now calls telemetryService.Stop() before StopAsync/ResolveCleanShutdown/
252+
// db.Close. This test drives the exact production launch shape: Close may beat
253+
// the Start goroutine entirely (the Stop-before-Start race) or catch the loop
254+
// mid-initial-delay; either way the service must be TERMINALLY stopped before
255+
// the marker resolves — proven by a post-Close Start returning immediately
256+
// instead of entering the heartbeat loop (which would park in its 5-minute
257+
// initial delay and could later write BBolt). The in-flight-tick join itself
258+
// is proven deterministically at the service level in
259+
// internal/telemetry/shutdown_test.go (TestServiceStopJoinsInFlightHeartbeatTick).
260+
func TestRuntimeCloseWaitsForTelemetryLoopBeforeMarkerResolve(t *testing.T) {
261+
t.Setenv("MCPPROXY_LAUNCHED_BY", "")
262+
// Clear env vars that would disable telemetry (CI sets CI=true): the loop
263+
// must actually start so Close has something to join.
264+
t.Setenv("CI", "")
265+
t.Setenv("DO_NOT_TRACK", "")
266+
t.Setenv("MCPPROXY_TELEMETRY", "")
267+
dataDir := t.TempDir()
268+
269+
rt, _ := previousShutdownVia(t, dataDir)
270+
svc := rt.TelemetryService()
271+
272+
// Launch exactly as StartBackgroundInitialization does.
273+
go svc.Start(rt.AppContext())
274+
275+
if err := rt.Close(); err != nil {
276+
t.Fatalf("close: %v", err)
277+
}
278+
279+
// Terminal stop: Start after Close must be a no-op that returns
280+
// immediately. If Close had only cancelled (not joined/terminally
281+
// stopped), this Start would run the loop and hang in its initial delay.
282+
startReturned := make(chan struct{})
283+
go func() {
284+
svc.Start(context.Background())
285+
close(startReturned)
286+
}()
287+
select {
288+
case <-startReturned:
289+
case <-time.After(2 * time.Second):
290+
t.Fatal("telemetry Start after Close did not no-op; the loop could write BBolt after the shutdown marker resolved")
291+
}
292+
293+
// Stop stays idempotent after Close (double-Close path).
294+
svc.Stop()
295+
svc.Stop()
296+
297+
// The marker resolved to clean with the telemetry loop joined first.
298+
rt2, prev := previousShutdownVia(t, dataDir)
299+
defer func() { _ = rt2.Close() }()
300+
if prev != "clean" {
301+
t.Fatalf("after graceful close with telemetry running: expected clean, got %q", prev)
302+
}
303+
}
304+
244305
// TestRuntimeCloseAfterExternalStopAsyncStillResolvesClean guards the split
245306
// Close sequence (Spec 080 FR-010, review round 3): Close now runs
246307
// storage.StopAsync (stop + drain queued async DB ops) BEFORE resolving the

internal/runtime/runtime.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,24 @@ func (r *Runtime) Close() error {
667667
r.activityService.Stop()
668668
}
669669

670+
// Spec 080 (FR-010, review round 6): the telemetry heartbeat loop is a
671+
// BBolt writer too — v7's buildHeartbeat records funnel activity
672+
// (funnelStore.RecordActivity) and the first tick clears the
673+
// installer-pending activation flag. The appCancel above stops the loop
674+
// between ticks and aborts an in-flight HTTP send promptly (the request
675+
// carries the loop context), but an in-flight tick must be JOINED, not
676+
// just cancelled — otherwise its BBolt write could land after the marker
677+
// below claims "clean", or against a closed DB. Stop blocks until the
678+
// loop (including any in-flight buildHeartbeat/sendHeartbeat, bounded by
679+
// the HTTP client's 10s timeout) has exited; it returns immediately when
680+
// Start never ran, is idempotent on double Close, and — like
681+
// ActivityService.Stop above — terminally stops the service so a Start
682+
// goroutine not yet scheduled (lifecycle.go launches it via `go`) becomes
683+
// a no-op instead of writing after this point.
684+
if r.telemetryService != nil {
685+
r.telemetryService.Stop()
686+
}
687+
670688
// Spec 080 (US3, FR-010): resolve the shutdown marker to "clean" at the
671689
// LAST point the DB is still open — i.e. after the async storage manager
672690
// has stopped AND drained its queue (those queued operations perform BBolt
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package telemetry
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"path/filepath"
8+
"sync/atomic"
9+
"testing"
10+
"time"
11+
12+
"go.etcd.io/bbolt"
13+
"go.uber.org/zap"
14+
15+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
16+
)
17+
18+
// Spec 080 (FR-010, review round 6): the heartbeat loop writes BBolt via
19+
// buildHeartbeat (funnelStore.RecordActivity), so Runtime.Close must be able
20+
// to JOIN the loop — not merely context-cancel it — before the clean-shutdown
21+
// marker resolves and the DB closes. These tests prove the Service-level
22+
// barrier: Stop waits for an in-flight tick, Stop-before-Start makes a late
23+
// Start a no-op, and Stop is idempotent.
24+
25+
// newShutdownTestDB creates a temporary BBolt DB for the funnel-store seam.
26+
func newShutdownTestDB(t *testing.T) *bbolt.DB {
27+
t.Helper()
28+
path := filepath.Join(t.TempDir(), "shutdown_test.db")
29+
db, err := bbolt.Open(path, 0o600, &bbolt.Options{Timeout: 2 * time.Second})
30+
if err != nil {
31+
t.Fatalf("bbolt.Open: %v", err)
32+
}
33+
t.Cleanup(func() { _ = db.Close() })
34+
return db
35+
}
36+
37+
// blockingFunnelStore parks the FIRST RecordActivity call until release is
38+
// closed — a deterministic stand-in for a slow BBolt write inside
39+
// buildHeartbeat, injected through the existing SetFunnelStore seam.
40+
type blockingFunnelStore struct {
41+
inFlight chan struct{} // closed when the first RecordActivity is entered
42+
release chan struct{} // the first RecordActivity returns after this closes
43+
completed atomic.Bool // set once a RecordActivity call has returned
44+
calls atomic.Int32
45+
}
46+
47+
func (b *blockingFunnelStore) IncrementWebUIOpened(*bbolt.DB) error { return nil }
48+
49+
func (b *blockingFunnelStore) RecordActivity(*bbolt.DB, time.Time) error {
50+
if b.calls.Add(1) == 1 {
51+
close(b.inFlight)
52+
<-b.release
53+
}
54+
b.completed.Store(true)
55+
return nil
56+
}
57+
58+
func (b *blockingFunnelStore) Snapshot(*bbolt.DB, time.Time) (FunnelState, error) {
59+
return FunnelState{}, nil
60+
}
61+
62+
// countingFunnelStore just counts RecordActivity calls.
63+
type countingFunnelStore struct {
64+
calls atomic.Int32
65+
}
66+
67+
func (c *countingFunnelStore) IncrementWebUIOpened(*bbolt.DB) error { return nil }
68+
func (c *countingFunnelStore) RecordActivity(*bbolt.DB, time.Time) error {
69+
c.calls.Add(1)
70+
return nil
71+
}
72+
func (c *countingFunnelStore) Snapshot(*bbolt.DB, time.Time) (FunnelState, error) {
73+
return FunnelState{}, nil
74+
}
75+
76+
// newShutdownTestService builds an enabled Service pointed at the given
77+
// endpoint with a fast initial delay and a one-tick-per-test interval.
78+
func newShutdownTestService(endpoint string) *Service {
79+
cfg := &config.Config{
80+
Telemetry: &config.TelemetryConfig{
81+
AnonymousID: "test-uuid-shutdown",
82+
Endpoint: endpoint,
83+
},
84+
RoutingMode: "retrieve_tools",
85+
}
86+
svc := New(cfg, "", "v1.0.0", "personal", zap.NewNop())
87+
svc.initialDelay = 5 * time.Millisecond
88+
svc.heartbeatInterval = time.Hour // exactly one tick within any test
89+
svc.SetRuntimeStats(&mockRuntimeStats{})
90+
return svc
91+
}
92+
93+
// TestServiceStopJoinsInFlightHeartbeatTick: Stop must block until an
94+
// in-flight heartbeat tick — parked inside its BBolt write — has fully
95+
// completed, even though the loop context is already cancelled. Before the
96+
// fix, Runtime.Close only context-cancelled the loop, so this write could
97+
// land after the shutdown marker resolved (or against a closed DB).
98+
func TestServiceStopJoinsInFlightHeartbeatTick(t *testing.T) {
99+
clearTelemetryEnv(t)
100+
101+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
102+
w.WriteHeader(http.StatusOK)
103+
}))
104+
defer server.Close()
105+
106+
svc := newShutdownTestService(server.URL)
107+
store := &blockingFunnelStore{
108+
inFlight: make(chan struct{}),
109+
release: make(chan struct{}),
110+
}
111+
svc.SetFunnelStore(store, newShutdownTestDB(t))
112+
113+
ctx, cancel := context.WithCancel(context.Background())
114+
defer cancel()
115+
go svc.Start(ctx)
116+
117+
// Wait until the first tick is INSIDE its funnel BBolt write.
118+
select {
119+
case <-store.inFlight:
120+
case <-time.After(5 * time.Second):
121+
t.Fatal("heartbeat tick never reached the funnel write")
122+
}
123+
124+
// Cancel first (as Runtime.Close's appCancel does), then Stop.
125+
cancel()
126+
stopReturned := make(chan struct{})
127+
go func() {
128+
svc.Stop()
129+
close(stopReturned)
130+
}()
131+
132+
// Stop must NOT return while the tick's write is still in flight.
133+
select {
134+
case <-stopReturned:
135+
t.Fatal("Stop returned while a heartbeat BBolt write was still in flight")
136+
case <-time.After(100 * time.Millisecond):
137+
}
138+
139+
// Release the write; Stop must now return, and only AFTER the write
140+
// completed (Stop-returned ⇒ loop exited ⇒ sendHeartbeat ⇒ buildHeartbeat
141+
// ⇒ RecordActivity returned — the race detector validates the ordering).
142+
close(store.release)
143+
select {
144+
case <-stopReturned:
145+
case <-time.After(5 * time.Second):
146+
t.Fatal("Stop did not return after the in-flight tick completed")
147+
}
148+
if !store.completed.Load() {
149+
t.Fatal("Stop returned before the in-flight funnel write completed")
150+
}
151+
}
152+
153+
// TestServiceStopBeforeStartMakesStartNoOp: production launches Start via
154+
// `go` (lifecycle.go), so a fast shutdown can run Stop BEFORE the Start
155+
// goroutine is scheduled. Stop must (a) return immediately and (b) terminally
156+
// stop the service so the late Start neither loops nor writes BBolt.
157+
func TestServiceStopBeforeStartMakesStartNoOp(t *testing.T) {
158+
clearTelemetryEnv(t)
159+
160+
var requests atomic.Int32
161+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
162+
requests.Add(1)
163+
w.WriteHeader(http.StatusOK)
164+
}))
165+
defer server.Close()
166+
167+
svc := newShutdownTestService(server.URL)
168+
svc.initialDelay = time.Millisecond // a buggy late Start would tick almost instantly
169+
store := &countingFunnelStore{}
170+
svc.SetFunnelStore(store, newShutdownTestDB(t))
171+
172+
// Stop before Start: must not block (Start never ran → nothing to join).
173+
stopReturned := make(chan struct{})
174+
go func() {
175+
svc.Stop()
176+
close(stopReturned)
177+
}()
178+
select {
179+
case <-stopReturned:
180+
case <-time.After(2 * time.Second):
181+
t.Fatal("Stop blocked although Start never ran")
182+
}
183+
184+
// The late Start must be a no-op that returns immediately — otherwise it
185+
// would sit in the heartbeat loop and write BBolt after the shutdown-
186+
// marker path already began.
187+
startReturned := make(chan struct{})
188+
go func() {
189+
svc.Start(context.Background())
190+
close(startReturned)
191+
}()
192+
select {
193+
case <-startReturned:
194+
case <-time.After(2 * time.Second):
195+
t.Fatal("Start after Stop did not no-op; the heartbeat loop is running past shutdown")
196+
}
197+
198+
if got := store.calls.Load(); got != 0 {
199+
t.Fatalf("funnel writes after Stop-before-Start: got %d, want 0", got)
200+
}
201+
if got := requests.Load(); got != 0 {
202+
t.Fatalf("heartbeats sent after Stop-before-Start: got %d, want 0", got)
203+
}
204+
}
205+
206+
// TestServiceStopIdempotentAndSecondStartRefused: Stop is safe to call
207+
// repeatedly (Runtime.Close on double Close), and the done bookkeeping is
208+
// single-shot — a second Start must be refused instead of re-running the
209+
// loop or double-closing the channel.
210+
func TestServiceStopIdempotentAndSecondStartRefused(t *testing.T) {
211+
clearTelemetryEnv(t)
212+
213+
svc := newShutdownTestService("http://127.0.0.1:0") // never contacted
214+
svc.initialDelay = time.Hour // first run parks in the initial delay
215+
216+
ctx, cancel := context.WithCancel(context.Background())
217+
cancel() // already cancelled: Start exits during the initial delay
218+
svc.Start(ctx)
219+
220+
svc.Stop() // loop already exited: returns immediately
221+
svc.Stop() // idempotent
222+
svc.Stop() // and a third
223+
224+
// A second Start must return immediately (single-shot); if it re-entered
225+
// the loop it would park in the 1h initial delay and hang here.
226+
startReturned := make(chan struct{})
227+
go func() {
228+
svc.Start(context.Background())
229+
close(startReturned)
230+
}()
231+
select {
232+
case <-startReturned:
233+
case <-time.After(2 * time.Second):
234+
t.Fatal("second Start was not refused")
235+
}
236+
svc.Stop() // still safe afterwards
237+
}

0 commit comments

Comments
 (0)