diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b8b498258..797bcdf1b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -268,9 +268,13 @@ initial-client delivery, and explicit client data requests; cancel delayed or queued work when its client leaves; and join every state producer before closing client channels. Reconnect, request, or invalidation churn must not multiply concurrent clones of that canonical payload or race shutdown sends -against channel closure. Ordinary unregister and slow-client eviction must use -the same synchronized send/close ownership; recovering a send-on-closed panic -is not a substitute for a race-free channel lifecycle. +against channel closure. A client's lifecycle cancellation signal must be +initialized and read through one synchronized owner before registration, +requested-state work, delayed initial-state delivery, or disconnect may race +over it; every path must observe the same stable signal. Ordinary unregister +and slow-client eviction must use the same synchronized send/close ownership; +recovering a send-on-closed panic is not a substitute for a race-free channel +lifecycle. Docker and Podman app-container CPU payloads expose two API facts with different meanings: canonical resource metrics and `/api/metrics-store/history` diff --git a/internal/websocket/hub_test.go b/internal/websocket/hub_test.go index 6eadbc6fa..d191423d3 100644 --- a/internal/websocket/hub_test.go +++ b/internal/websocket/hub_test.go @@ -35,6 +35,47 @@ func TestClientSafeSendAndCloseAreSynchronized(t *testing.T) { } } +func TestClientLifecycleSignalConcurrentInitializationAndClose(t *testing.T) { + client := &Client{} + start := make(chan struct{}) + signals := make([]chan struct{}, 32) + var workers sync.WaitGroup + + for i := range signals { + workers.Add(1) + go func(index int) { + defer workers.Done() + <-start + signals[index] = client.lifecycleSignal() + }(i) + } + for i := 0; i < 8; i++ { + workers.Add(1) + go func() { + defer workers.Done() + <-start + client.closeLifecycle() + }() + } + + close(start) + workers.Wait() + + for i, signal := range signals { + if signal == nil { + t.Fatalf("lifecycle signal %d was nil", i) + } + if signal != signals[0] { + t.Fatalf("lifecycle signal %d did not share the client lifecycle", i) + } + } + select { + case <-signals[0]: + default: + t.Fatal("lifecycle signal remained open after closeLifecycle") + } +} + func TestHubDisconnectedClientsDoNotBuildInitialState(t *testing.T) { var stateBuilds atomic.Int64 hub := NewHub(func(string) interface{} {