Skip to content

Commit f4a4c39

Browse files
committed
fix: race condition when flush() does not complete
1 parent c4aa363 commit f4a4c39

2 files changed

Lines changed: 25 additions & 51 deletions

File tree

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,13 @@ it('duplicate-mode setConnectionMode awaits an in-flight transition to the same
296296
localStoragePath: tmpRoot,
297297
});
298298

299-
// Keeps the queue draining when p3 is issued, making the race deterministic.
299+
// flush() must not reject during the offline leg so p1 does not poison the queue.
300300
const flushSpy = jest
301301
.spyOn(LDClientImpl.prototype, 'flush')
302302
.mockImplementation(() => Promise.resolve({ result: true }));
303303

304-
// p3 hits the idempotency guard because p2 sets _connectionMode synchronously,
305-
// before p2's queued task runs. Without the await, p3 resolves too early.
304+
// p3 queues behind p2; by the time p3's task runs, p2 has already committed
305+
// _connectionMode to 'streaming', so the idempotency check fires and p3 no-ops.
306306
const p1 = client.setConnectionMode('offline');
307307
const p2 = client.setConnectionMode('streaming');
308308
const p3 = client.setConnectionMode('streaming');
@@ -348,35 +348,31 @@ it('setConnectionMode with an invalid mode logs a warning and does not delegate
348348
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid-mode'));
349349
});
350350

351-
// ------ Mode rollback on failed transition ------
351+
// ------ Failed offline transition ------
352352

353-
it('restores connection mode when an FDv2 offline transition task throws', async () => {
353+
it('setConnectionMode rejects and does not change connection mode when flush fails in FDv2', async () => {
354354
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
355355
dataSystem: {},
356356
diagnosticOptOut: true,
357357
sendEvents: false,
358358
logger,
359359
localStoragePath: tmpRoot,
360360
});
361-
// flush() is the first async step, so a reject here means setConnectionMode
362-
// on the data manager is never reached.
361+
// flush() throws before _connectionMode is written or the data manager is notified.
363362
const flushSpy = jest
364363
.spyOn(LDClientImpl.prototype, 'flush')
365364
.mockRejectedValueOnce(new Error('flush failed'));
366365

367366
await expect(client.setConnectionMode('offline')).rejects.toThrow('flush failed');
368367

369-
// The synchronously-set 'offline' is rolled back: the data manager is still
370-
// streaming, so isOffline() reflects actual state.
371368
expect(client.getConnectionMode()).toBe('streaming');
372369
expect(client.isOffline()).toBe(false);
373-
// The data manager was never told to go offline.
374370
expect(mockSetConnectionMode).not.toHaveBeenCalled();
375371

376372
flushSpy.mockRestore();
377373
});
378374

379-
it('keeps the FDv2 transition queue alive for subsequent calls after a failed transition', async () => {
375+
it('setConnectionMode queue stays alive after a failed offline transition in FDv2', async () => {
380376
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
381377
dataSystem: {},
382378
diagnosticOptOut: true,
@@ -391,7 +387,7 @@ it('keeps the FDv2 transition queue alive for subsequent calls after a failed tr
391387
await expect(client.setConnectionMode('offline')).rejects.toThrow('flush failed');
392388
flushSpy.mockRestore();
393389

394-
// A subsequent transition must still work; the queue was not poisoned.
390+
// task.catch() keeps _fdv2ConnectionModeQueue alive; the next call must succeed.
395391
await client.setConnectionMode('polling');
396392
expect(client.getConnectionMode()).toBe('polling');
397393
expect(mockSetConnectionMode).toHaveBeenCalledWith('polling');

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

Lines changed: 17 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,13 @@ import NodePlatform from './platform/NodePlatform';
3636

3737
export class NodeClient extends LDClientImpl {
3838
private readonly _plugins: LDPlugin[];
39-
// Tracks the current connection mode for both FDv1 (NodeDataManager) and
40-
// FDv2 paths. Updated synchronously so getConnectionMode()/isOffline()
41-
// reflect the caller's intent without waiting for async data manager work.
39+
// Connection mode for both paths. FDv2 updates this inside the
40+
// serialized queue task; FDv1 reads it back from the data manager after each
41+
// transition.
4242
private _connectionMode: ConnectionMode;
43-
// Serializes FDv2 connection-mode transitions so concurrent calls cannot
44-
// reorder flush/event-sending around the await in the offline branch.
43+
// Serializes FDv2 mode transitions so concurrent callers cannot interleave
44+
// flush() and setEventSendingEnabled() across the offline branch's await.
4545
private _fdv2ConnectionModeQueue: Promise<void> = Promise.resolve();
46-
// Set to true when close() is called so post-close setConnectionMode calls
47-
// are no-ops in the FDv2 path.
4846
private _closed: boolean = false;
4947

5048
constructor(envKey: string, initialContext: LDContext, options: NodeOptions = {}) {
@@ -180,50 +178,30 @@ export class NodeClient extends LDClientImpl {
180178

181179
async setConnectionMode(mode: ConnectionMode): Promise<void> {
182180
if (this.isFDv2) {
183-
// Validate mode before forwarding to prevent a runtime crash inside the
184-
// debounce timer when an invalid value is passed from JavaScript.
181+
// JS callers can pass arbitrary strings; validate before the debounce timer fires.
185182
if (mode !== 'offline' && mode !== 'streaming' && mode !== 'polling') {
186183
this.logger.warn(`[NodeClient] Unknown connection mode "${mode}", ignoring.`);
187184
return;
188185
}
189186
if (this._closed) {
190187
return;
191188
}
192-
if (mode === this._connectionMode) {
193-
// Await without appending: an earlier call may still be transitioning to
194-
// this same mode, and we must not resolve until it has finished.
195-
await this._fdv2ConnectionModeQueue;
196-
return;
197-
}
198-
// Without serialization, concurrent calls can interleave flush() and
199-
// setEventSendingEnabled() across an offline await, corrupting order.
200-
// Capture the prior mode so a failed transition can be rolled back:
201-
// otherwise isOffline()/getConnectionMode() would report a state the
202-
// data manager never actually entered.
203-
const previousMode = this._connectionMode;
204-
this._connectionMode = mode;
205189
const task = this._fdv2ConnectionModeQueue.then(async () => {
206190
// Re-check: close() may have been called while this task was queued.
207191
if (this._closed) {
208192
return;
209193
}
210-
try {
211-
if (mode === 'offline') {
212-
await this.flush();
213-
this.setEventSendingEnabled(false, false);
214-
}
215-
(this.dataManager as FDv2DataManagerControl).setConnectionMode(mode);
216-
if (mode !== 'offline') {
217-
this.setEventSendingEnabled(true, false);
218-
}
219-
} catch (e) {
220-
// Only roll back if no later queued task has already moved past this
221-
// mode; otherwise isOffline()/getConnectionMode() would drift from
222-
// the state the data manager actually reached.
223-
if (this._connectionMode === mode) {
224-
this._connectionMode = previousMode;
225-
}
226-
throw e;
194+
if (mode === this._connectionMode) {
195+
return;
196+
}
197+
if (mode === 'offline') {
198+
await this.flush();
199+
this.setEventSendingEnabled(false, false);
200+
}
201+
(this.dataManager as FDv2DataManagerControl).setConnectionMode(mode);
202+
this._connectionMode = mode;
203+
if (mode !== 'offline') {
204+
this.setEventSendingEnabled(true, false);
227205
}
228206
});
229207
this._fdv2ConnectionModeQueue = task.catch(() => {});

0 commit comments

Comments
 (0)