Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

πŸ› Avoid stack overflow during shrinking of tuples #3428

Merged
merged 10 commits into from
Dec 1, 2022
7 changes: 7 additions & 0 deletions .yarn/versions/2ae83f1c.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
releases:
fast-check: patch

declined:
- "@fast-check/ava"
- "@fast-check/jest"
- "@fast-check/worker"
28 changes: 18 additions & 10 deletions packages/fast-check/src/arbitrary/_internals/TupleArbitrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cloneIfNeeded, cloneMethod, WithCloneMethod } from '../../check/symbols
import { Arbitrary } from '../../check/arbitrary/definition/Arbitrary';
import { Value } from '../../check/arbitrary/definition/Value';
import { safeMap, safePush, safeSlice } from '../../utils/globals';
import { makeLazy } from '../../stream/LazyIterableIterator';

const safeArrayIsArray = Array.isArray;
const safeObjectDefineProperty = Object.defineProperty;
Expand Down Expand Up @@ -52,19 +53,26 @@ export function tupleShrink<Ts extends unknown[]>(
): Stream<TupleExtendedValue<Ts>> {
// shrinking one by one is the not the most comprehensive
// but allows a reasonable number of entries in the shrink
let s = Stream.nil<TupleExtendedValue<Ts>>();
const shrinks: IterableIterator<TupleExtendedValue<Ts>>[] = [];
const safeContext: TupleContext = safeArrayIsArray(context) ? context : [];
for (let idx = 0; idx !== arbs.length; ++idx) {
const shrinksForIndex: Stream<TupleExtendedValue<Ts>> = arbs[idx]
.shrink(value[idx], safeContext[idx])
.map((v) => {
const nextValues: Value<unknown>[] = safeMap(value, (v, idx) => new Value(cloneIfNeeded(v), safeContext[idx]));
return [...safeSlice(nextValues, 0, idx), v, ...safeSlice(nextValues, idx + 1)];
})
.map((values) => tupleWrapper(values) as TupleExtendedValue<Ts>);
s = s.join(shrinksForIndex);
safePush(
shrinks,
makeLazy(() =>
arbs[idx]
.shrink(value[idx], safeContext[idx])
.map((v) => {
const nextValues: Value<unknown>[] = safeMap(
value,
(v, idx) => new Value(cloneIfNeeded(v), safeContext[idx])
);
return [...safeSlice(nextValues, 0, idx), v, ...safeSlice(nextValues, idx + 1)];
})
.map((values) => tupleWrapper(values) as TupleExtendedValue<Ts>)
)
);
}
return s;
return Stream.nil<TupleExtendedValue<Ts>>().join(...shrinks);
}

/** @internal */
Expand Down
11 changes: 11 additions & 0 deletions packages/fast-check/test/e2e/NoStackOverflowOnShrink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ describe(`NoStackOverflowOnShrink (seed: ${seed})`, () => {
expect(() => iterateOverShrunkValues(arb, value!)).not.toThrow();
});

it('should not run into stack overflow while calling shrink on very large tuple', () => {
// We expect the depth used by this test to be greater than
// the maximal depth we computed before reaching a stack overflow
expect(maxDepthForArrays).toBeGreaterThan(callStackSizeWithMargin);

const mrng = new fc.Random(prand.xorshift128plus(seed));
const arb = fc.tuple<boolean[]>(...[...Array(maxDepthForArrays)].fill(fc.boolean()));
const value: fc.Value<boolean[]> = arb.generate(mrng, undefined);
expect(() => iterateOverShrunkValues(arb, value)).not.toThrow();
});

it('should not run into stack overflow while calling shrink on very large shuffled sub-arrays', () => {
// We expect the depth used by this test to be greater than
// the maximal depth we computed before reaching a stack overflow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,15 @@ describe('AsyncProperty', () => {

// Act
const p = asyncProperty(arb, jest.fn());
const shrinks = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
const shrinksStream = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
expect(canShrinkWithoutContext).not.toHaveBeenCalled(); // lazy evaluation of shrink for tuples
const shrinks = [...shrinksStream];

// Assert
expect(canShrinkWithoutContext).toHaveBeenCalledWith(value);
expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
expect(shrink).not.toHaveBeenCalled();
expect([...shrinks]).toEqual([]);
expect(shrinks).toEqual([]);
});
it('should call shrink on the arbitrary if no context but properly handled value', () => {
// Arrange
Expand All @@ -328,13 +330,16 @@ describe('AsyncProperty', () => {

// Act
const p = asyncProperty(arb, jest.fn());
const shrinks = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
const shrinksStream = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
expect(canShrinkWithoutContext).not.toHaveBeenCalled(); // lazy evaluation of shrink for tuples
expect(shrink).not.toHaveBeenCalled();
const shrinks = [...shrinksStream];

// Assert
expect(canShrinkWithoutContext).toHaveBeenCalledWith(value);
expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
expect(shrink).toHaveBeenCalledWith(value, undefined);
expect(shrink).toHaveBeenCalledTimes(1);
expect([...shrinks].map((s) => s.value_)).toEqual([[s1], [s2]]);
expect(shrinks.map((s) => s.value_)).toEqual([[s1], [s2]]);
});
});
13 changes: 9 additions & 4 deletions packages/fast-check/test/unit/check/property/Property.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,15 @@ describe('Property', () => {

// Act
const p = property(arb, jest.fn());
const shrinks = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
const shrinksStream = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
expect(canShrinkWithoutContext).not.toHaveBeenCalled(); // lazy evaluation of shrink for tuples
const shrinks = [...shrinksStream];

// Assert
expect(canShrinkWithoutContext).toHaveBeenCalledWith(value);
expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
expect(shrink).not.toHaveBeenCalled();
expect([...shrinks]).toEqual([]);
expect(shrinks).toEqual([]);
});
it('should call shrink on the arbitrary if no context but properly handled value', () => {
// Arrange
Expand All @@ -299,13 +301,16 @@ describe('Property', () => {

// Act
const p = property(arb, jest.fn());
const shrinks = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
const shrinksStream = p.shrink(new Value([value], undefined)); // context=undefined in the case of user defined values
expect(canShrinkWithoutContext).not.toHaveBeenCalled(); // lazy evaluation of shrink for tuples
expect(shrink).not.toHaveBeenCalled();
const shrinks = [...shrinksStream];

// Assert
expect(canShrinkWithoutContext).toHaveBeenCalledWith(value);
expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
expect(shrink).toHaveBeenCalledWith(value, undefined);
expect(shrink).toHaveBeenCalledTimes(1);
expect([...shrinks].map((s) => s.value_)).toEqual([[s1], [s2]]);
expect(shrinks.map((s) => s.value_)).toEqual([[s1], [s2]]);
});
});