connor4312/cockatiel
CountBreaker state exposes samples array by reference, causing shared state and negative counters
Summary
Context:
CountBreakeris a circuit breaker policy that tracks failures in a sliding window using an internal array of boolean samples, with success/failure counters that must stay synchronized with the array contents.Bug: The
stategetter returns the internalsamplesarray by reference instead of a copy, and thestatesetter usesObject.assignwhich copies the reference, causing multiple breaker instances to share the same underlying array.Actual vs. expected: When state is copied between two
CountBreakerinstances without JSON serialization, they share the samesamplesarray, causing modifications to one breaker to corrupt the other’s internal state (thesamplesarray changes but counters don’t update). Expected behavior is that each breaker instance has independent state.Impact: When one breaker modifies the shared array, the other breaker’s
samplesarray is modified without updating itssuccesses/failurescounters, leading to negative counter values and incorrect circuit-breaking decisions.
Code with bug
public get state(): unknown { return { samples: this.samples, // <-- BUG 🔴 Returns array by reference, not a copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
public get state(): unknown { return { samples: this.samples, // <-- BUG 🔴 Returns array by reference, not a copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
public get state(): unknown { return { samples: this.samples, // <-- BUG 🔴 Returns array by reference, not a copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
public set state(value: unknown) { Object.assign(this, value); // <-- BUG 🔴 Copies array reference, causing sharing }
public set state(value: unknown) { Object.assign(this, value); // <-- BUG 🔴 Copies array reference, causing sharing }
public set state(value: unknown) { Object.assign(this, value); // <-- BUG 🔴 Copies array reference, causing sharing }
Failing test
import { expect } from 'chai'; import { CircuitState } from './CircuitBreakerPolicy'; import { CountBreaker } from './breaker/CountBreaker'; describe('CountBreaker samples array reference bug', () => { it('demonstrates that samples array is shared by reference, causing state corruption', () => { // Create breaker1 with some state const breaker1 = new CountBreaker({ threshold: 0.5, size: 5 }); // Add 3 failures to breaker1 breaker1.failure(CircuitState.Closed); // samples[0] = false, failures = 1 breaker1.failure(CircuitState.Closed); // samples[1] = false, failures = 2 breaker1.failure(CircuitState.Closed); // samples[2] = false, failures = 3 // Verify breaker1's initial state expect((breaker1 as any).failures).to.equal(3); expect((breaker1 as any).successes).to.equal(0); expect((breaker1 as any).currentSample).to.equal(3); // Get state from breaker1 const state = breaker1.state; // Create breaker2 with fresh state, then assign breaker1's state const breaker2 = new CountBreaker({ threshold: 0.5, size: 5 }); breaker2.state = state; // Verify that breaker1 and breaker2 share the SAME samples array const breaker1Samples = (breaker1 as any).samples; const breaker2Samples = (breaker2 as any).samples; expect(breaker1Samples).to.equal(breaker2Samples); // They are the same object! // Now modify breaker2 by calling success breaker2.success(CircuitState.Closed); // samples[3] = true, successes = 1 // Check breaker2's state - this should be correct expect((breaker2 as any).failures).to.equal(3); expect((breaker2 as any).successes).to.equal(1); expect((breaker2 as any).samples[3]).to.equal(true); expect((breaker2 as any).currentSample).to.equal(4); // Check breaker1's state - THIS REVEALS THE BUG expect((breaker1 as any).failures).to.equal(3); // Still 3 (not updated) expect((breaker1 as any).successes).to.equal(0); // Still 0 (not updated) expect((breaker1 as any).samples[3]).to.equal(true); // CHANGED! (bug) expect((breaker1 as any).currentSample).to.equal(3); // Still 3 (not updated) // When breaker1 is used next, it will try to replace samples[3] // It will decrement successes (making it -1!) because samples[3] is true const breaker1SuccessesBefore = (breaker1 as any).successes; // 0 breaker1.failure(CircuitState.Closed); // Replace samples[3] with false const breaker1SuccessesAfter = (breaker1 as any).successes; // 0 - 1 = -1 ! // The bug: successes is now negative! expect(breaker1SuccessesAfter).to.equal(-1); // This demonstrates the corruption }); });
import { expect } from 'chai'; import { CircuitState } from './CircuitBreakerPolicy'; import { CountBreaker } from './breaker/CountBreaker'; describe('CountBreaker samples array reference bug', () => { it('demonstrates that samples array is shared by reference, causing state corruption', () => { // Create breaker1 with some state const breaker1 = new CountBreaker({ threshold: 0.5, size: 5 }); // Add 3 failures to breaker1 breaker1.failure(CircuitState.Closed); // samples[0] = false, failures = 1 breaker1.failure(CircuitState.Closed); // samples[1] = false, failures = 2 breaker1.failure(CircuitState.Closed); // samples[2] = false, failures = 3 // Verify breaker1's initial state expect((breaker1 as any).failures).to.equal(3); expect((breaker1 as any).successes).to.equal(0); expect((breaker1 as any).currentSample).to.equal(3); // Get state from breaker1 const state = breaker1.state; // Create breaker2 with fresh state, then assign breaker1's state const breaker2 = new CountBreaker({ threshold: 0.5, size: 5 }); breaker2.state = state; // Verify that breaker1 and breaker2 share the SAME samples array const breaker1Samples = (breaker1 as any).samples; const breaker2Samples = (breaker2 as any).samples; expect(breaker1Samples).to.equal(breaker2Samples); // They are the same object! // Now modify breaker2 by calling success breaker2.success(CircuitState.Closed); // samples[3] = true, successes = 1 // Check breaker2's state - this should be correct expect((breaker2 as any).failures).to.equal(3); expect((breaker2 as any).successes).to.equal(1); expect((breaker2 as any).samples[3]).to.equal(true); expect((breaker2 as any).currentSample).to.equal(4); // Check breaker1's state - THIS REVEALS THE BUG expect((breaker1 as any).failures).to.equal(3); // Still 3 (not updated) expect((breaker1 as any).successes).to.equal(0); // Still 0 (not updated) expect((breaker1 as any).samples[3]).to.equal(true); // CHANGED! (bug) expect((breaker1 as any).currentSample).to.equal(3); // Still 3 (not updated) // When breaker1 is used next, it will try to replace samples[3] // It will decrement successes (making it -1!) because samples[3] is true const breaker1SuccessesBefore = (breaker1 as any).successes; // 0 breaker1.failure(CircuitState.Closed); // Replace samples[3] with false const breaker1SuccessesAfter = (breaker1 as any).successes; // 0 - 1 = -1 ! // The bug: successes is now negative! expect(breaker1SuccessesAfter).to.equal(-1); // This demonstrates the corruption }); });
import { expect } from 'chai'; import { CircuitState } from './CircuitBreakerPolicy'; import { CountBreaker } from './breaker/CountBreaker'; describe('CountBreaker samples array reference bug', () => { it('demonstrates that samples array is shared by reference, causing state corruption', () => { // Create breaker1 with some state const breaker1 = new CountBreaker({ threshold: 0.5, size: 5 }); // Add 3 failures to breaker1 breaker1.failure(CircuitState.Closed); // samples[0] = false, failures = 1 breaker1.failure(CircuitState.Closed); // samples[1] = false, failures = 2 breaker1.failure(CircuitState.Closed); // samples[2] = false, failures = 3 // Verify breaker1's initial state expect((breaker1 as any).failures).to.equal(3); expect((breaker1 as any).successes).to.equal(0); expect((breaker1 as any).currentSample).to.equal(3); // Get state from breaker1 const state = breaker1.state; // Create breaker2 with fresh state, then assign breaker1's state const breaker2 = new CountBreaker({ threshold: 0.5, size: 5 }); breaker2.state = state; // Verify that breaker1 and breaker2 share the SAME samples array const breaker1Samples = (breaker1 as any).samples; const breaker2Samples = (breaker2 as any).samples; expect(breaker1Samples).to.equal(breaker2Samples); // They are the same object! // Now modify breaker2 by calling success breaker2.success(CircuitState.Closed); // samples[3] = true, successes = 1 // Check breaker2's state - this should be correct expect((breaker2 as any).failures).to.equal(3); expect((breaker2 as any).successes).to.equal(1); expect((breaker2 as any).samples[3]).to.equal(true); expect((breaker2 as any).currentSample).to.equal(4); // Check breaker1's state - THIS REVEALS THE BUG expect((breaker1 as any).failures).to.equal(3); // Still 3 (not updated) expect((breaker1 as any).successes).to.equal(0); // Still 0 (not updated) expect((breaker1 as any).samples[3]).to.equal(true); // CHANGED! (bug) expect((breaker1 as any).currentSample).to.equal(3); // Still 3 (not updated) // When breaker1 is used next, it will try to replace samples[3] // It will decrement successes (making it -1!) because samples[3] is true const breaker1SuccessesBefore = (breaker1 as any).successes; // 0 breaker1.failure(CircuitState.Closed); // Replace samples[3] with false const breaker1SuccessesAfter = (breaker1 as any).successes; // 0 - 1 = -1 ! // The bug: successes is now negative! expect(breaker1SuccessesAfter).to.equal(-1); // This demonstrates the corruption }); });
Test output (key observations):
breaker1Samples === breaker2Samples-> true (same array reference)After
breaker1.failure(...),breaker1SuccessesAfter-> -1 (assertion passes)
Recommended fix
The state getter should return a copy of the samples array instead of a reference:
public get state(): unknown { return { samples: [...this.samples], // <-- FIX 🟢 Return a shallow copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
public get state(): unknown { return { samples: [...this.samples], // <-- FIX 🟢 Return a shallow copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
public get state(): unknown { return { samples: [...this.samples], // <-- FIX 🟢 Return a shallow copy currentSample: this.currentSample, failures: this.failures, successes: this.successes, } satisfies ICountBreakerState; }
Alternatively, the state setter could defensively clone the incoming array:
public set state(value: unknown) { const typed = value as ICountBreakerState; this.samples = [...typed.samples]; // <-- FIX 🟢 Clone the array this.currentSample = typed.currentSample; this.failures = typed.failures; this.successes = typed.successes; }
public set state(value: unknown) { const typed = value as ICountBreakerState; this.samples = [...typed.samples]; // <-- FIX 🟢 Clone the array this.currentSample = typed.currentSample; this.failures = typed.failures; this.successes = typed.successes; }
public set state(value: unknown) { const typed = value as ICountBreakerState; this.samples = [...typed.samples]; // <-- FIX 🟢 Clone the array this.currentSample = typed.currentSample; this.failures = typed.failures; this.successes = typed.successes; }
The getter fix is preferred because:
It prevents the issue at the source
It maintains the invariant that the returned state is independent
It’s consistent with principle of encapsulation
It has minimal performance impact (shallow copy of a fixed-size array)