|
5 | 5 | "errors" |
6 | 6 | "strings" |
7 | 7 | "sync" |
| 8 | + "sync/atomic" |
8 | 9 | "testing" |
9 | 10 | "time" |
10 | 11 |
|
@@ -896,3 +897,113 @@ func TestSupervisor_ErrorCodeNotifierSynchronous(t *testing.T) { |
896 | 897 | t.Fatalf("notifier received a non-MCPX code: %q", got) |
897 | 898 | } |
898 | 899 | } |
| 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