From 89960221dbb4e03bfab07b60109d696e6c9bde8f Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 26 Nov 2025 16:47:58 -0500 Subject: [PATCH 1/5] Check for infinite loops --- packages/core/src/stateUtils.ts | 9 ++++ packages/core/test/transient.test.ts | 69 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index f17a831d80..9e56443479 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1691,8 +1691,17 @@ export function macrostep( } let shouldSelectEventlessTransitions = true; + const maxIterations = 1000; // Safety limit to prevent infinite loops + 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/test/transient.test.ts b/packages/core/test/transient.test.ts index a7d1b57108..2646defa52 100644 --- a/packages/core/test/transient.test.ts +++ b/packages/core/test/transient.test.ts @@ -566,6 +566,75 @@ 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', + states: { + a: { + always: { + target: 'b' + } + }, + b: { + always: { + target: 'c' + } + }, + c: { + always: { + target: 'a' + } + } + } + }); + const actor = createActor(machine); + + actor.subscribe({ + error: (err) => { + expect(err).toMatchInlineSnapshot(/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' + } + } + } + }); + const actor = createActor(machine); + + actor.subscribe({ + error: (err) => { + expect(err).toMatchInlineSnapshot(/infinite loop/i); + } + }); + + actor.start(); + }); + it("shouldn't end up in an infinite loop when selecting the fallback target", () => { const machine = createMachine({ initial: 'idle', From 792bc8a7dbfc03943253227c7092d178139bb49f Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 10 Dec 2025 19:40:27 -0500 Subject: [PATCH 2/5] Add runtime options for state machine execution - Introduced `MachineOptions` interface to define runtime options, including `maxIterations`. - Updated `StateMachine` class to accept and initialize options from the configuration. - Modified `macrostep` function to utilize the `maxIterations` option, allowing for dynamic iteration limits. --- packages/core/src/StateMachine.ts | 8 ++++++++ packages/core/src/stateUtils.ts | 2 +- packages/core/src/types.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index f070310083..573166bf3d 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: 1000, + ...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 960017aff3..f275b0c551 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1648,7 +1648,7 @@ export function macrostep( } let shouldSelectEventlessTransitions = true; - const maxIterations = 1000; // Safety limit to prevent infinite loops + const maxIterations = snapshot.machine.options?.maxIterations ?? 1000; let iterationCount = 0; while (nextSnapshot.status === 'active') { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7e516e7948..c5dcf3fe19 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1343,6 +1343,31 @@ 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 1000. + * + * @default 1000 + */ + maxIterations?: number; +} + export type MachineConfig< TContext extends MachineContext, TEvent extends EventObject, @@ -1375,6 +1400,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> } From 8fecaac7f82bb0116258b2c8db2982e7a8a980ce Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 12 Dec 2025 21:43:04 -0500 Subject: [PATCH 3/5] Update maxIterations handling in state machine - Changed default value of `maxIterations` in `MachineOptions` to `Infinity` for no limit. - Enhanced `macrostep` function to support dynamic iteration limits, throwing an error for infinite loops only when a valid limit is set. - Updated tests to reflect new `maxIterations` behavior. --- .changeset/giant-doors-like.md | 16 ++++++++++++++++ packages/core/src/stateUtils.ts | 5 +++-- packages/core/src/types.ts | 5 +++-- packages/core/test/transient.test.ts | 6 ++++++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/giant-doors-like.md 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/stateUtils.ts b/packages/core/src/stateUtils.ts index f275b0c551..777595caee 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1648,12 +1648,13 @@ export function macrostep( } let shouldSelectEventlessTransitions = true; - const maxIterations = snapshot.machine.options?.maxIterations ?? 1000; + const maxIterations = snapshot.machine.options?.maxIterations ?? Infinity; + const hasMaxIterations = maxIterations !== Infinity && maxIterations !== -1; let iterationCount = 0; while (nextSnapshot.status === 'active') { iterationCount++; - if (iterationCount > maxIterations) { + if (hasMaxIterations && 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).` ); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c5dcf3fe19..1a38609235 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1361,9 +1361,10 @@ export type ContextFactory< export interface MachineOptions { /** * Maximum number of microsteps allowed before throwing an infinite loop - * error. Defaults to 1000. + * error. Defaults to `Infinity` (no limit). Set to a number to enable + * infinite loop detection, or `-1` to explicitly disable the limit. * - * @default 1000 + * @default Infinity */ maxIterations?: number; } diff --git a/packages/core/test/transient.test.ts b/packages/core/test/transient.test.ts index 2646defa52..5df81959a8 100644 --- a/packages/core/test/transient.test.ts +++ b/packages/core/test/transient.test.ts @@ -570,6 +570,9 @@ describe('transient states (eventless transitions)', () => { expect.assertions(1); const machine = createMachine({ initial: 'a', + options: { + maxIterations: 100 + }, states: { a: { always: { @@ -622,6 +625,9 @@ describe('transient states (eventless transitions)', () => { target: 'a' } } + }, + options: { + maxIterations: 100 } }); const actor = createActor(machine); From e500583b008616dde44169183ff598c2926e8568 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 5 Jan 2026 09:08:56 -0500 Subject: [PATCH 4/5] Refactor tests --- packages/core/test/transient.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/transient.test.ts b/packages/core/test/transient.test.ts index 5df81959a8..78aef75e54 100644 --- a/packages/core/test/transient.test.ts +++ b/packages/core/test/transient.test.ts @@ -595,7 +595,7 @@ describe('transient states (eventless transitions)', () => { actor.subscribe({ error: (err) => { - expect(err).toMatchInlineSnapshot(/infinite loop/i); + expect((err as any).message).toMatch(/infinite loop/i); } }); @@ -634,7 +634,7 @@ describe('transient states (eventless transitions)', () => { actor.subscribe({ error: (err) => { - expect(err).toMatchInlineSnapshot(/infinite loop/i); + expect((err as any).message).toMatch(/infinite loop/i); } }); From 451f68290222cde94484bd605e9cf8120023f937 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 8 Jan 2026 10:43:33 -0500 Subject: [PATCH 5/5] Address comments --- packages/core/src/StateMachine.ts | 2 +- packages/core/src/stateUtils.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index 573166bf3d..9870b1017e 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -140,7 +140,7 @@ export class StateMachine< this.version = this.config.version; this.schemas = this.config.schemas; this.options = { - maxIterations: 1000, + maxIterations: Infinity, ...this.config.options }; diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 777595caee..606b064c80 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1649,12 +1649,11 @@ export function macrostep( let shouldSelectEventlessTransitions = true; const maxIterations = snapshot.machine.options?.maxIterations ?? Infinity; - const hasMaxIterations = maxIterations !== Infinity && maxIterations !== -1; let iterationCount = 0; while (nextSnapshot.status === 'active') { iterationCount++; - if (hasMaxIterations && iterationCount > maxIterations) { + 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).` );