Skip to content

Commit e0655ea

Browse files
committed
chore: fix bot comments
fix: Restore FDv2 connection mode when an offline transition fails fix: Await in-flight FDv2 transition on duplicate-mode setConnectionMode
1 parent e4d6de4 commit e0655ea

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

packages/sdk/node-client/__tests__/NodeClientFDv2.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ jest.mock('@launchdarkly/js-client-sdk-common', () => ({
5656
}),
5757
}));
5858

59+
import { LDClientImpl } from '@launchdarkly/js-client-sdk-common';
60+
5961
import { createClient } from '../src';
6062

6163
const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'bob' };
@@ -285,6 +287,43 @@ it('setConnectionMode is a no-op when called with the current mode in FDv2', asy
285287
expect(client.getConnectionMode()).toBe('streaming');
286288
});
287289

290+
it('duplicate-mode setConnectionMode awaits an in-flight transition to the same mode in FDv2', async () => {
291+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
292+
dataSystem: {},
293+
diagnosticOptOut: true,
294+
sendEvents: false,
295+
logger,
296+
localStoragePath: tmpRoot,
297+
});
298+
299+
// Make the offline transition's flush() defer to a later microtask so the
300+
// offline -> streaming queue is still draining when the duplicate streaming
301+
// call is issued. This exposes the early-return race deterministically.
302+
const flushSpy = jest
303+
.spyOn(LDClientImpl.prototype, 'flush')
304+
.mockImplementation(() => Promise.resolve({ result: true }));
305+
306+
// Sequence: initial mode is 'streaming'. Go offline, then streaming, then
307+
// streaming again (the duplicate). The third call's _connectionMode is already
308+
// 'streaming' (set synchronously by the second call), so the idempotency guard
309+
// fires. Without the await fix, p3 resolves before the second call's queued
310+
// task has delegated 'streaming' to the data manager.
311+
const p1 = client.setConnectionMode('offline');
312+
const p2 = client.setConnectionMode('streaming');
313+
const p3 = client.setConnectionMode('streaming');
314+
315+
// Awaiting only the duplicate call must guarantee the queued streaming
316+
// transition has completed (mockSetConnectionMode called with 'streaming').
317+
await p3;
318+
expect(mockSetConnectionMode).toHaveBeenCalledWith('streaming');
319+
320+
await Promise.all([p1, p2]);
321+
expect(client.getConnectionMode()).toBe('streaming');
322+
expect(client.isOffline()).toBe(false);
323+
324+
flushSpy.mockRestore();
325+
});
326+
288327
// ------ Post-close guard ------
289328

290329
it('setConnectionMode after close is a no-op in FDv2', async () => {
@@ -315,3 +354,51 @@ it('setConnectionMode with an invalid mode logs a warning and does not delegate
315354
expect(mockSetConnectionMode).not.toHaveBeenCalled();
316355
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid-mode'));
317356
});
357+
358+
// ------ Mode rollback on failed transition ------
359+
360+
it('restores connection mode when an FDv2 offline transition task throws', async () => {
361+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
362+
dataSystem: {},
363+
diagnosticOptOut: true,
364+
sendEvents: false,
365+
logger,
366+
localStoragePath: tmpRoot,
367+
});
368+
// Force the queued offline transition task to throw by making flush() reject.
369+
const flushSpy = jest
370+
.spyOn(LDClientImpl.prototype, 'flush')
371+
.mockRejectedValueOnce(new Error('flush failed'));
372+
373+
await expect(client.setConnectionMode('offline')).rejects.toThrow('flush failed');
374+
375+
// The synchronously-set 'offline' must be rolled back to the prior mode so
376+
// isOffline() does not lie while the data manager is still streaming.
377+
expect(client.getConnectionMode()).toBe('streaming');
378+
expect(client.isOffline()).toBe(false);
379+
// The data manager was never told to go offline.
380+
expect(mockSetConnectionMode).not.toHaveBeenCalled();
381+
382+
flushSpy.mockRestore();
383+
});
384+
385+
it('keeps the FDv2 transition queue alive for subsequent calls after a failed transition', async () => {
386+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
387+
dataSystem: {},
388+
diagnosticOptOut: true,
389+
sendEvents: false,
390+
logger,
391+
localStoragePath: tmpRoot,
392+
});
393+
const flushSpy = jest
394+
.spyOn(LDClientImpl.prototype, 'flush')
395+
.mockRejectedValueOnce(new Error('flush failed'));
396+
397+
await expect(client.setConnectionMode('offline')).rejects.toThrow('flush failed');
398+
flushSpy.mockRestore();
399+
400+
// A subsequent transition must still work; the queue was not poisoned.
401+
await client.setConnectionMode('polling');
402+
expect(client.getConnectionMode()).toBe('polling');
403+
expect(mockSetConnectionMode).toHaveBeenCalledWith('polling');
404+
});

packages/sdk/node-client/src/NodeClient.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,24 +190,47 @@ export class NodeClient extends LDClientImpl {
190190
return;
191191
}
192192
if (mode === this._connectionMode) {
193+
// The requested mode matches the optimistically-set _connectionMode, so
194+
// no new transition is queued. But _connectionMode is set synchronously
195+
// before the queued task runs, so an earlier call's transition to this
196+
// same mode may still be in flight. Await the queue tail (without
197+
// appending to it) so the caller's promise resolves only after that
198+
// transition has actually completed. If nothing is in flight,
199+
// _fdv2ConnectionModeQueue is already resolved, so this is a no-op.
200+
await this._fdv2ConnectionModeQueue;
193201
return;
194202
}
195203
// FDv2 data manager: serialize transitions so concurrent calls cannot
196204
// reorder flush/setEventSendingEnabled around the offline await.
205+
// Capture the prior mode so a failed transition can be rolled back -
206+
// otherwise isOffline()/getConnectionMode() would report a state the
207+
// data manager never actually entered.
208+
const previousMode = this._connectionMode;
197209
this._connectionMode = mode;
198210
const task = this._fdv2ConnectionModeQueue.then(async () => {
199211
// Re-check after any preceding tasks have run — close() may have fired
200212
// while this task was queued, matching the FDv1 NodeDataManager guard.
201213
if (this._closed) {
202214
return;
203215
}
204-
if (mode === 'offline') {
205-
await this.flush();
206-
this.setEventSendingEnabled(false, false);
207-
}
208-
(this.dataManager as FDv2DataManagerControl).setConnectionMode(mode);
209-
if (mode !== 'offline') {
210-
this.setEventSendingEnabled(true, false);
216+
try {
217+
if (mode === 'offline') {
218+
await this.flush();
219+
this.setEventSendingEnabled(false, false);
220+
}
221+
(this.dataManager as FDv2DataManagerControl).setConnectionMode(mode);
222+
if (mode !== 'offline') {
223+
this.setEventSendingEnabled(true, false);
224+
}
225+
} catch (e) {
226+
// The transition did not complete; restore the requested-mode marker
227+
// so isOffline()/getConnectionMode() stay consistent with the data
228+
// manager. Only roll back if no later queued task has already moved
229+
// us on from this transition's target mode.
230+
if (this._connectionMode === mode) {
231+
this._connectionMode = previousMode;
232+
}
233+
throw e;
211234
}
212235
});
213236
this._fdv2ConnectionModeQueue = task.catch(() => {});

0 commit comments

Comments
 (0)