Skip to content

Commit f7a9d03

Browse files
committed
fix(telemetry): close startup-vs-shutdown races in marker write barriers (Spec 080, review round 5)
1 parent bdb8f71 commit f7a9d03

4 files changed

Lines changed: 202 additions & 6 deletions

File tree

internal/runtime/activity_service.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,19 @@ type ActivityService struct {
4848
// background loops (retention, usage flush) and the per-event async
4949
// detection goroutines, all of which write to BBolt. startMu/started make
5050
// Stop return immediately when Start never ran (done would never close).
51+
// stopped is the terminal state (Spec 080, review round 5): production
52+
// launches Start via `go` (lifecycle.go), so a fast shutdown can run Stop
53+
// BEFORE the Start goroutine is scheduled — Stop marks stopped under
54+
// startMu and a later Start becomes a no-op instead of launching BBolt
55+
// writers after the shutdown-marker path began. Start's registration
56+
// (subscribe + every workersWG.Add) happens entirely under startMu, so a
57+
// Stop that loses the race blocks until registration is complete and its
58+
// Wait cannot miss a late worker.
5159
done chan struct{}
5260
workersWG sync.WaitGroup
5361
startMu sync.Mutex
5462
started bool
63+
stopped bool
5564

5665
// Retention configuration
5766
maxAge time.Duration
@@ -127,20 +136,29 @@ func (s *ActivityService) SetRetentionConfig(maxAge time.Duration, maxRecords in
127136
// Start begins listening for activity events and persisting them.
128137
// It should be called as a goroutine: go svc.Start(ctx, runtime)
129138
func (s *ActivityService) Start(ctx context.Context, rt *Runtime) {
130-
// Mark started so Stop knows the done channel WILL close; refuse a second
131-
// Start (the done/WaitGroup bookkeeping is single-shot).
139+
// Registration runs entirely under startMu (Spec 080 FR-010, review round
140+
// 5). If Stop already ran (fast shutdown beat this goroutine — production
141+
// launches Start via `go`), the service is terminally stopped: return
142+
// without subscribing or launching any BBolt-writing worker. Otherwise
143+
// mark started so Stop knows the done channel WILL close, and refuse a
144+
// second Start (the done/WaitGroup bookkeeping is single-shot). Holding
145+
// startMu through every workersWG.Add below means a concurrent Stop
146+
// blocks until registration is complete — its Wait cannot miss a worker.
132147
s.startMu.Lock()
148+
if s.stopped {
149+
s.startMu.Unlock()
150+
s.logger.Debug("Activity service Start called after Stop; not starting")
151+
return
152+
}
133153
if s.started {
134154
s.startMu.Unlock()
135155
s.logger.Warn("Activity service Start called twice; ignoring")
136156
return
137157
}
138158
s.started = true
139-
s.startMu.Unlock()
140159

141160
// Subscribe to runtime events
142161
eventCh := rt.SubscribeEvents()
143-
defer rt.UnsubscribeEvents(eventCh)
144162

145163
// Start retention loop in a separate goroutine. Tracked in workersWG: it
146164
// prunes activity records (BBolt writes), so Stop must await it.
@@ -158,6 +176,9 @@ func (s *ActivityService) Start(ctx context.Context, rt *Runtime) {
158176
defer s.workersWG.Done()
159177
s.runUsageFlushLoop(ctx)
160178
}()
179+
s.startMu.Unlock()
180+
181+
defer rt.UnsubscribeEvents(eventCh)
161182

162183
s.logger.Info("Activity service started")
163184

@@ -255,8 +276,16 @@ func (s *ActivityService) runRetentionCleanup() {
255276
// flush runs on ctx.Done inside the event loop, and Stop waits for it, so the
256277
// flush is captured before the marker resolves. Idempotent, and returns
257278
// immediately when Start never ran.
279+
//
280+
// Stop is also terminal (Spec 080, review round 5): it marks stopped under
281+
// startMu, so a Start that has not yet registered (production starts the
282+
// service via `go` in lifecycle.go) becomes a no-op instead of launching
283+
// retention/usage/persist loops after the shutdown-marker path began. If
284+
// Start is mid-registration, acquiring startMu here blocks until every
285+
// workersWG.Add has happened, so the Wait below cannot miss a worker.
258286
func (s *ActivityService) Stop() {
259287
s.startMu.Lock()
288+
s.stopped = true
260289
started := s.started
261290
s.startMu.Unlock()
262291
if !started {

internal/runtime/activity_service_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package runtime
22

33
import (
4+
"context"
45
"os"
56
"testing"
67
"time"
@@ -919,3 +920,42 @@ func TestHandleToolCallCompleted_ByteCapture(t *testing.T) {
919920
assert.Equal(t, 1500, record.RequestBytes, "RequestBytes must reflect pre-truncation request size")
920921
assert.Equal(t, 98304, record.ResponseBytes, "ResponseBytes must reflect pre-truncation response size")
921922
}
923+
924+
// TestActivityServiceStartAfterStopIsNoOp (Spec 080 FR-010, review round 5):
925+
// production launches Start via `go r.activityService.Start(...)` in
926+
// lifecycle.go, so a fast shutdown can run Stop BEFORE the Start goroutine is
927+
// ever scheduled. Stop must leave a terminal stopped state that turns the
928+
// late Start into a no-op — no retention/usage/persist loops launched, no
929+
// storage access. Storage and runtime are nil on purpose: any registration
930+
// step (SubscribeEvents, initUsageFromStorage, the worker loops) would panic,
931+
// so a regression fails loudly.
932+
func TestActivityServiceStartAfterStopIsNoOp(t *testing.T) {
933+
svc := NewActivityService(nil, zap.NewNop())
934+
935+
// Fast shutdown wins the race: Stop runs before Start ever does. It must
936+
// return immediately (Start never ran, done never closes).
937+
svc.Stop()
938+
939+
// The delayed Start must return immediately without registering anything.
940+
// A non-no-op Start would either panic (nil rt/storage) or block forever
941+
// in the event loop (ctx is never cancelled) and trip the timeout below.
942+
returned := make(chan struct{})
943+
go func() {
944+
defer close(returned)
945+
svc.Start(context.Background(), nil)
946+
}()
947+
select {
948+
case <-returned:
949+
case <-time.After(2 * time.Second):
950+
t.Fatal("Start after Stop did not return; it must be a no-op")
951+
}
952+
953+
svc.startMu.Lock()
954+
started := svc.started
955+
svc.startMu.Unlock()
956+
assert.False(t, started, "Start after Stop must not mark the service started")
957+
958+
// Stop stays idempotent after the no-op Start.
959+
svc.Stop()
960+
svc.Stop()
961+
}

internal/runtime/supervisor/supervisor.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,25 @@ func (s *Supervisor) Start() {
240240
s.wg.Add(1)
241241
go s.exemptionCleanupLoop()
242242

243-
// Phase 7.1: Trigger initial reconciliation to populate StateView
243+
// Phase 7.1: Trigger initial reconciliation to populate StateView.
244+
// Registered in s.wg with a ctx-aware timer (Spec 080 FR-010/FR-011,
245+
// review round 5): Stop() cancels s.ctx and then waits on s.wg, so it
246+
// either cancels this goroutine inside the 500ms warm-up window or waits
247+
// for the reconcile to finish. A bare time.Sleep goroutine here could wake
248+
// AFTER Stop() returned and write last_error_code/diagnostics via
249+
// reconcile()/updateStateView()/notifyErrorCode() — after the clean-
250+
// shutdown marker resolved, or against a closed DB.
251+
s.wg.Add(1)
244252
go func() {
245-
time.Sleep(500 * time.Millisecond) // Give servers time to connect
253+
defer s.wg.Done()
254+
timer := time.NewTimer(500 * time.Millisecond) // Give servers time to connect
255+
defer timer.Stop()
256+
select {
257+
case <-s.ctx.Done():
258+
s.logger.Debug("Supervisor stopping before initial reconciliation")
259+
return
260+
case <-timer.C:
261+
}
246262
currentConfig := s.configSvc.Current()
247263
if err := s.reconcile(currentConfig); err != nil {
248264
s.logger.Error("Initial reconciliation failed", zap.Error(err))

internal/runtime/supervisor/supervisor_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"strings"
77
"sync"
8+
"sync/atomic"
89
"testing"
910
"time"
1011

@@ -896,3 +897,113 @@ func TestSupervisor_ErrorCodeNotifierSynchronous(t *testing.T) {
896897
t.Fatalf("notifier received a non-MCPX code: %q", got)
897898
}
898899
}
900+
901+
// barrierUpstreamAdapter wraps MockUpstreamAdapter and counts any upstream
902+
// call that happens after the barrier is armed. Used to prove Stop() is a
903+
// write barrier for the delayed initial-reconciliation goroutine (Spec 080).
904+
type barrierUpstreamAdapter struct {
905+
*MockUpstreamAdapter
906+
barrier atomic.Bool
907+
violations atomic.Int32
908+
}
909+
910+
func (b *barrierUpstreamAdapter) check() {
911+
if b.barrier.Load() {
912+
b.violations.Add(1)
913+
}
914+
}
915+
916+
func (b *barrierUpstreamAdapter) AddServer(name string, cfg *config.ServerConfig) error {
917+
b.check()
918+
return b.MockUpstreamAdapter.AddServer(name, cfg)
919+
}
920+
921+
func (b *barrierUpstreamAdapter) RemoveServer(name string) error {
922+
b.check()
923+
return b.MockUpstreamAdapter.RemoveServer(name)
924+
}
925+
926+
func (b *barrierUpstreamAdapter) ConnectServer(ctx context.Context, name string) error {
927+
b.check()
928+
return b.MockUpstreamAdapter.ConnectServer(ctx, name)
929+
}
930+
931+
func (b *barrierUpstreamAdapter) DisconnectServer(name string) error {
932+
b.check()
933+
return b.MockUpstreamAdapter.DisconnectServer(name)
934+
}
935+
936+
func (b *barrierUpstreamAdapter) ConnectAll(ctx context.Context) error {
937+
b.check()
938+
return b.MockUpstreamAdapter.ConnectAll(ctx)
939+
}
940+
941+
func (b *barrierUpstreamAdapter) GetServerState(name string) (*ServerState, error) {
942+
b.check()
943+
return b.MockUpstreamAdapter.GetServerState(name)
944+
}
945+
946+
func (b *barrierUpstreamAdapter) GetAllStates() map[string]*ServerState {
947+
b.check()
948+
return b.MockUpstreamAdapter.GetAllStates()
949+
}
950+
951+
func (b *barrierUpstreamAdapter) IsUserLoggedOut(name string) bool {
952+
b.check()
953+
return b.MockUpstreamAdapter.IsUserLoggedOut(name)
954+
}
955+
956+
// TestSupervisor_StopBeforeInitialReconcileIsBarrier (Spec 080 FR-010/FR-011,
957+
// review round 5): Start() arms a delayed (500ms) initial reconciliation.
958+
// Stop() must be a barrier for it — the goroutine is registered in s.wg and
959+
// waits on a ctx-aware timer, so Stop() (cancel + wg.Wait) deterministically
960+
// either cancels it inside the window or waits for the reconcile to finish.
961+
// Before the fix it was a bare `go func() { time.Sleep(500ms); reconcile() }`:
962+
// Stop() returned with nothing to wait for, Runtime.Close resolved the clean-
963+
// shutdown marker, and the goroutine then woke and wrote diagnostics/state
964+
// (reconcile -> updateStateView -> notifyErrorCode) after the marker — or
965+
// against a closed DB.
966+
func TestSupervisor_StopBeforeInitialReconcileIsBarrier(t *testing.T) {
967+
cfg := &config.Config{
968+
Listen: "127.0.0.1:8080",
969+
Servers: []*config.ServerConfig{
970+
{Name: "late-server", Enabled: true},
971+
},
972+
}
973+
974+
configSvc := configsvc.NewService(cfg, "/tmp/config.json", zap.NewNop())
975+
defer configSvc.Close()
976+
977+
upstream := &barrierUpstreamAdapter{MockUpstreamAdapter: NewMockUpstreamAdapter()}
978+
sup := New(configSvc, upstream, zap.NewNop())
979+
980+
var stopReturned atomic.Bool
981+
var notifierAfterStop atomic.Int32
982+
sup.SetErrorCodeNotifier(func(string) {
983+
if stopReturned.Load() {
984+
notifierAfterStop.Add(1)
985+
}
986+
})
987+
988+
sup.Start()
989+
sup.Stop() // well inside the 500ms initial-reconcile delay
990+
991+
// Everything the supervisor owns (reconciliation loop, event forwarding,
992+
// exemption cleanup, the initial-reconcile goroutine, in-flight actions)
993+
// is joined by Stop() via s.wg / drainActions, so from this point on no
994+
// upstream call and no notifier call may ever happen again.
995+
upstream.barrier.Store(true)
996+
stopReturned.Store(true)
997+
998+
// Regression net: the pre-fix bare goroutine would wake ~500ms after
999+
// Start() and run reconcile() against the stopped supervisor. Wait out the
1000+
// full window plus slack, then assert the barrier held. With the fix this
1001+
// sleep is pure idle time — the wg-joined goroutine already exited before
1002+
// Stop() returned, via the s.ctx.Done() arm of its select.
1003+
time.Sleep(700 * time.Millisecond)
1004+
1005+
require.Zero(t, upstream.violations.Load(),
1006+
"upstream adapter was called after Supervisor.Stop() returned")
1007+
require.Zero(t, notifierAfterStop.Load(),
1008+
"error-code notifier fired after Supervisor.Stop() returned")
1009+
}

0 commit comments

Comments
 (0)