diff --git a/.changeset/giant-doors-like.md b/.changeset/giant-doors-like.md new file mode 100644 index 0000000000..68eddf6658 --- /dev/null +++ b/.changeset/giant-doors-like.md @@ -0,0 +1,16 @@ +--- +'xstate': minor +--- + +Add `maxIterations` option to configure the maximum number of microsteps allowed before throwing an infinite loop error. The default is `Infinity` (no limit) to avoid breaking existing machines. + +You can configure it when creating a machine: + +```ts +const machine = createMachine({ + // ... machine config + options: { + maxIterations: 1000 // set a limit to enable infinite loop detection + } +}); +``` diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index f070310083..9870b1017e 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -38,6 +38,7 @@ import type { MachineConfig, MachineContext, MachineImplementationsSimplified, + MachineOptions, MetaObject, ParameterizedObject, ProvidedActor, @@ -94,6 +95,9 @@ export class StateMachine< public implementations: MachineImplementationsSimplified; + /** Runtime options for machine execution. */ + public options: MachineOptions; + /** @internal */ public __xstatenode = true as const; @@ -135,6 +139,10 @@ export class StateMachine< }; this.version = this.config.version; this.schemas = this.config.schemas; + this.options = { + maxIterations: Infinity, + ...this.config.options + }; this.transition = this.transition.bind(this); this.getInitialSnapshot = this.getInitialSnapshot.bind(this); diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 2eacb67493..606b064c80 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1648,8 +1648,17 @@ export function macrostep( } let shouldSelectEventlessTransitions = true; + const maxIterations = snapshot.machine.options?.maxIterations ?? Infinity; + let iterationCount = 0; while (nextSnapshot.status === 'active') { + iterationCount++; + if (iterationCount > maxIterations) { + throw new Error( + `Infinite loop detected: the machine has processed more than ${maxIterations} microsteps without reaching a stable state. This usually happens when there's a cycle of transitions (e.g., eventless transitions or raised events causing state A -> B -> C -> A).` + ); + } + let enabledTransitions: AnyTransitionDefinition[] = shouldSelectEventlessTransitions ? selectEventlessTransitions(nextSnapshot, nextEvent) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7e516e7948..1a38609235 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1343,6 +1343,32 @@ export type ContextFactory< >; }) => TContext; +/** + * Runtime options for state machine execution. + * + * @example + * + * ```ts + * const machine = createMachine({ + * // ... machine config + * options: { + * maxIterations: 5000 + * // other runtime options can be added here + * } + * }); + * ``` + */ +export interface MachineOptions { + /** + * Maximum number of microsteps allowed before throwing an infinite loop + * error. Defaults to `Infinity` (no limit). Set to a number to enable + * infinite loop detection, or `-1` to explicitly disable the limit. + * + * @default Infinity + */ + maxIterations?: number; +} + export type MachineConfig< TContext extends MachineContext, TEvent extends EventObject, @@ -1375,6 +1401,8 @@ export type MachineConfig< version?: string; // TODO: make it conditionally required output?: Mapper | TOutput; + /** Runtime options for machine execution. */ + options?: MachineOptions; }) & (MachineContext extends TContext ? { context?: InitialContext, TActor, TInput, TEvent> } diff --git a/packages/core/test/transient.test.ts b/packages/core/test/transient.test.ts index a7d1b57108..78aef75e54 100644 --- a/packages/core/test/transient.test.ts +++ b/packages/core/test/transient.test.ts @@ -566,6 +566,81 @@ describe('transient states (eventless transitions)', () => { service.send({ type: 'EVENT', value: 42 }); }); + it('should avoid infinite loops with eventless transitions', () => { + expect.assertions(1); + const machine = createMachine({ + initial: 'a', + options: { + maxIterations: 100 + }, + states: { + a: { + always: { + target: 'b' + } + }, + b: { + always: { + target: 'c' + } + }, + c: { + always: { + target: 'a' + } + } + } + }); + const actor = createActor(machine); + + actor.subscribe({ + error: (err) => { + expect((err as any).message).toMatch(/infinite loop/i); + } + }); + + actor.start(); + }); + + it('should avoid infinite loops with raised events', () => { + expect.assertions(1); + const machine = createMachine({ + initial: 'a', + states: { + a: { + always: { + target: 'b' + } + }, + b: { + entry: raise({ type: 'EVENT' }), + on: { + EVENT: { + target: 'c' + } + } + }, + c: { + always: { + target: 'a' + } + } + }, + options: { + maxIterations: 100 + } + }); + const actor = createActor(machine); + + actor.subscribe({ + error: (err) => { + expect((err as any).message).toMatch(/infinite loop/i); + } + }); + + actor.start(); + }); + it("shouldn't end up in an infinite loop when selecting the fallback target", () => { const machine = createMachine({ initial: 'idle',