From 42e89c7f711f907c5802e82aa7fb11e771862484 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:03:23 +0000 Subject: [PATCH 1/2] feat: Add goodbye-event FDv1 fallback directive test (SDK-2511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test that exercises the in-band goodbye SSE event form of the FDv1 Fallback Directive (protocolFallbackTTL field on the goodbye event), per FDV2PL spec §3.7 and CSFDV2 spec §8.3.4. This transport is essential for browser SDKs using native EventSource, which cannot read HTTP response headers on the streaming connection. Changes: - Add ProtocolFallbackTTL field to framework.Goodbye struct - Add PushGoodbyeWithFallback to StreamingService - Add DirectiveViaGoodbyeEngagesFDv1 test to the FDv1FallbackDirective suite Co-Authored-By: Bee Klimt --- framework/types.go | 9 ++- mockld/streaming_service.go | 11 ++++ sdktests/common_tests_stream_fdv2.go | 84 +++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/framework/types.go b/framework/types.go index 5d765f3e..4257fcbf 100644 --- a/framework/types.go +++ b/framework/types.go @@ -61,9 +61,8 @@ type ErrorEvent struct { type Heartbeat struct{} type Goodbye struct { - Reason string `json:"reason"` - Silent bool `json:"silent"` - Catastrophe bool `json:"catastrophe"` - //nolint:godox - // TODO: Might later include some advice or backoff information + Reason string `json:"reason"` + Silent bool `json:"silent"` + Catastrophe bool `json:"catastrophe"` + ProtocolFallbackTTL *int `json:"protocolFallbackTTL,omitempty"` } diff --git a/mockld/streaming_service.go b/mockld/streaming_service.go index 4e297ee2..e217caef 100644 --- a/mockld/streaming_service.go +++ b/mockld/streaming_service.go @@ -263,6 +263,17 @@ func (s *StreamingService) PushGoodbye(reason string, silent, catastrophe bool) }) } +// PushGoodbyeWithFallback sends a goodbye event carrying a protocolFallbackTTL +// field, which signals the SDK to fall back from FDv2 to FDv1 (see FDV2PL spec +// §3.7 and CSFDV2 spec §8.3.4). ttlSeconds is the duration in seconds the SDK +// should remain on FDv1 before attempting recovery; 0 means indefinite. +func (s *StreamingService) PushGoodbyeWithFallback(reason string, ttlSeconds int) { + s.PushEvent("goodbye", framework.Goodbye{ + Reason: reason, + ProtocolFallbackTTL: &ttlSeconds, + }) +} + func (s *StreamingService) PushPayloadTransferred(state string, version int) { eventData := framework.PayloadTransferred{ State: state, diff --git a/sdktests/common_tests_stream_fdv2.go b/sdktests/common_tests_stream_fdv2.go index 4315bd8c..7835b254 100644 --- a/sdktests/common_tests_stream_fdv2.go +++ b/sdktests/common_tests_stream_fdv2.go @@ -30,7 +30,7 @@ import ( // Tests use this to stand up a realistic FDv1 Fallback Synchronizer endpoint regardless // of SDK kind. func fdv1FallbackBody( - t *ldtest.T, c CommonStreamingTests, flagKey string, + t *ldtest.T, c CommonStreamingTests, flagKey string, //nolint:unparam // kept as a parameter for API consistency version int, value ldvalue.Value, ) []byte { @@ -500,6 +500,8 @@ func (c CommonStreamingTests) FDv1FallbackDirective(t *ldtest.T) { c.DirectiveWithoutFDv1ConfiguredHaltsDataSystem) t.Run("directed fallback is terminal and does not revisit FDv2 sources", c.DirectedFallbackIsTerminal) + t.Run("goodbye event with protocolFallbackTTL engages FDv1 fallback", + c.DirectiveViaGoodbyeEngagesFDv1) } // DirectiveOnStreamingErrorEngagesFDv1 verifies Requirement 1.6.1 and 1.6.3 for the @@ -951,6 +953,86 @@ func (c CommonStreamingTests) DirectedFallbackIsTerminal(t *ldtest.T) { "flag-key should still be served from the FDv1 fallback after the recovery window elapses") } +// DirectiveViaGoodbyeEngagesFDv1 verifies the in-band goodbye-event form of the FDv1 +// Fallback Directive (FDV2PL spec §3.7, CSFDV2 spec §8.3.4): a `goodbye` SSE event +// whose JSON body contains a `protocolFallbackTTL` field MUST cause the SDK to engage +// the FDv1 Fallback Synchronizer, just as the `X-LD-FD-Fallback` response header does. +// +// This transport is essential for browser SDKs that use the native EventSource API, +// which does not expose HTTP response headers on the streaming connection. The goodbye +// message provides the same fallback information in-band. +// +// The test delivers a full FDv2 basis via streaming so the SDK initializes normally, +// then sends the goodbye event with protocolFallbackTTL. The FDv1 endpoint serves a +// different flag value; the test asserts the SDK eventually returns that FDv1 value, +// proving the transition completed and FDv1 data is the active source. +func (c CommonStreamingTests) DirectiveViaGoodbyeEngagesFDv1(t *ldtest.T) { + streamingValue := ldvalue.String("value-from-streaming") + streamingData := c.makeSDKDataWithFlag(1, streamingValue) + dataSystem := NewSDKDataSystem(t, streamingData) + + fdv1Value := ldvalue.String("value-from-fdv1-via-goodbye") + fdv1Handler, fdv1Channel := httphelpers.RecordingHandler( + httphelpers.HandlerWithResponse(200, http.Header{"Content-Type": []string{"application/json"}}, + fdv1FallbackBody(t, c, "flag-key", 2, fdv1Value))) + fdv1Endpoint := requireContext(t).harness.NewMockEndpoint(fdv1Handler, t.DebugLogger(), + harness.MockEndpointDescription("FDv1 polling service (goodbye path)")) + t.Defer(fdv1Endpoint.Close) + + client := c.newFDv2SDKClient(t, + WithConfig(servicedef.SDKConfigParams{ + StartWaitTimeMS: o.Some(ldtime.UnixMillisecondTime(5 * time.Second / time.Millisecond)), + }), + WithServiceEndpoints(servicedef.SDKConfigServiceEndpointsParams{ + Streaming: dataSystem.Synchronizers[0].Endpoint().BaseURL(), + Polling: fdv1Endpoint.BaseURL(), + }), + dataSystem, + WithFDv1Fallback(servicedef.SDKConfigPollingParams{ + BaseURI: fdv1Endpoint.BaseURL(), + })) + + // Wait for the SDK to initialize from the streaming basis. + streamEndpoint := dataSystem.Synchronizers[0].Endpoint() + conn, err := streamEndpoint.AwaitConnection(time.Second * 2) + require.NoError(t, err) + + context := c.flagEvaluationContext + h.RequireEventually(t, func() bool { + value := basicEvaluateFlag(t, client, "flag-key", context, defaultValue) + return m.In(t).Assert(value, m.JSONEqual(streamingValue)) + }, time.Second*3, time.Millisecond*20, + "flag-key should have been served from the streaming payload before the goodbye event") + + // Send the goodbye event with protocolFallbackTTL to trigger FDv1 fallback. + // TTL 0 means indefinite fallback — the SDK must not attempt FDv2 recovery. + dataSystem.Synchronizers[0].streaming.PushGoodbyeWithFallback("fdv1-fallback-directed", 0) + conn.Cancel() + + // The SDK must contact the FDv1 polling endpoint after the goodbye directive. + h.RequireEventually(t, func() bool { + select { + case resp := <-fdv1Channel: + return fdv1FallbackPollPathMatches(c.sdkKind, resp.Request.URL.Path) + default: + return false + } + }, time.Second*3, time.Millisecond*10, + "FDv1 fallback endpoint was never contacted after goodbye event with protocolFallbackTTL") + + // Evaluation must eventually reflect FDv1's value, proving the SDK installed + // the FDv1 payload and is serving from it rather than the stale streaming basis. + h.RequireEventually(t, func() bool { + value := basicEvaluateFlag(t, client, "flag-key", context, defaultValue) + return m.In(t).Assert(value, m.JSONEqual(fdv1Value)) + }, time.Second*3, time.Millisecond*20, + "flag-key should have been served from the FDv1 fallback after goodbye with protocolFallbackTTL") + + // The FDv2 streaming endpoint must not see any reconnection attempts — the + // directive is terminal and the SDK must not return to FDv2. + streamEndpoint.RequireNoMoreConnections(t, time.Millisecond*500) +} + func (c CommonStreamingTests) SavesPreviouslyKnownState(t *ldtest.T) { dataBefore := c.makeSDKDataWithFlag(1, initialValue) var dataAfter mockld.FDv2SDKData From 2dd18197ef69bea51a95fa37609d3758d0e12dbc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:18:20 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Remove=20conn.Cancel()=20race=20?= =?UTF-8?q?=E2=80=94=20wait=20for=20SDK=20to=20close=20stream=20after=20go?= =?UTF-8?q?odbye?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of force-cancelling the connection (which races the SDK's goodbye event processing), wait for the SDK to close the streaming connection itself via context cancellation. This proves the SDK actually processed the protocolFallbackTTL directive rather than just seeing a dropped connection. Co-Authored-By: Bee Klimt --- sdktests/common_tests_stream_fdv2.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/sdktests/common_tests_stream_fdv2.go b/sdktests/common_tests_stream_fdv2.go index 7835b254..fb0bcc85 100644 --- a/sdktests/common_tests_stream_fdv2.go +++ b/sdktests/common_tests_stream_fdv2.go @@ -1006,8 +1006,24 @@ func (c CommonStreamingTests) DirectiveViaGoodbyeEngagesFDv1(t *ldtest.T) { // Send the goodbye event with protocolFallbackTTL to trigger FDv1 fallback. // TTL 0 means indefinite fallback — the SDK must not attempt FDv2 recovery. + // We do NOT cancel the connection ourselves — the SDK must close the stream + // after processing the directive. Forcing a cancel here would race the SDK's + // event processing and could mask a bug where the SDK ignores the goodbye. dataSystem.Synchronizers[0].streaming.PushGoodbyeWithFallback("fdv1-fallback-directed", 0) - conn.Cancel() + + // The SDK must close the FDv2 streaming connection after processing the + // goodbye directive. The request context is tied to the TCP connection; if the + // SDK correctly stopped the Primary Synchronizer, the context will be cancelled. + h.RequireEventually(t, func() bool { + select { + case <-conn.Context.Done(): + return true + default: + return false + } + }, time.Second*3, time.Millisecond*20, + "SDK did not close the FDv2 streaming connection after goodbye with protocolFallbackTTL — "+ + "the Primary Synchronizer must be stopped when Directed Fallback engages") // The SDK must contact the FDv1 polling endpoint after the goodbye directive. h.RequireEventually(t, func() bool {