Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/giant-doors-like.md
Original file line number Diff line number Diff line change
@@ -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
}
});
```
8 changes: 8 additions & 0 deletions packages/core/src/StateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
MachineConfig,
MachineContext,
MachineImplementationsSimplified,
MachineOptions,
MetaObject,
ParameterizedObject,
ProvidedActor,
Expand Down Expand Up @@ -94,6 +95,9 @@ export class StateMachine<

public implementations: MachineImplementationsSimplified<TContext, TEvent>;

/** Runtime options for machine execution. */
public options: MachineOptions;

/** @internal */
public __xstatenode = true as const;

Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1648,8 +1648,18 @@ 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) {
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)
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1375,6 +1401,8 @@ export type MachineConfig<
version?: string;
// TODO: make it conditionally required
output?: Mapper<TContext, DoneStateEvent, TOutput, TEvent> | TOutput;
/** Runtime options for machine execution. */
options?: MachineOptions;
}) &
(MachineContext extends TContext
? { context?: InitialContext<LowInfer<TContext>, TActor, TInput, TEvent> }
Expand Down
75 changes: 75 additions & 0 deletions packages/core/test/transient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +629 to +631
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's yet another place when some options are accepted. I'm not saying this is inherently bad but I can see how this might not be that discoverable given this is the only option accepted this way now.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wonder, shouldn't this kinda be a global option really? As a user, I don't see myself using this everywhere - and currently I'd have to because it's opt-in.

Some of other libraries (like even React) just implements such protections automatically and they don't allow the user to confgure the "depth count" that triggers the error.

});
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',
Expand Down