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

Bug: actors leak uncaught errors #4928

Open
boneskull opened this issue Jun 7, 2024 · 13 comments
Open

Bug: actors leak uncaught errors #4928

boneskull opened this issue Jun 7, 2024 · 13 comments

Comments

@boneskull
Copy link
Contributor

XState version

XState version 5

Description

Given:

  1. Root machine actor A
  2. No error subscriber is registered with A
  3. A's initial context assignment function throws an error E

E cannot be caught by any mechanism other than a global error listener (e.g., window.onerror or process.on('uncaughtException').

Even if a machine actor is wrapped in toPromise(), E is still thrown this way in addition to being rejected out of the Promise.

This behavior does not occur if the machine actor has a subscriber with an error listener.

I really don't want to create a bunch of subscriptions where toPromise() would work for my purposes. 😄

Expected result

The error thrown by the context initializer should not be thrown in addition to being rejected.

Actual result

The "extra" error thrown is only catchable via a global error handler.

Reproduction

https://stackblitz.com/edit/github-trfney?file=src%2Fmain.ts

Additional context

This may be expected behavior. I understand that an actor that is started without an error listener and isn't wrapped in a Promise has no other choice but to throw an error this way; there's nowhere for it to go.

In Node.js, an EE will emit an error event, which can be listened to. If it is listened to, then the EE won't throw an uncaught exception. However, the once wrapper (which is only vaguely analogous to toPromise()) will also avoid the uncaught exception:

import {once, EventEmitter} from 'node:events';

const emitter = new EventEmitter();

// without this, the error would be uncaught
once(emitter, 'error').then(err => {
  // handle error
});

emitter.emit('error', new Error('handled'));

That's why I think this is a bug. Like above, toPromise should act like a subscriber with an error listener.

It may also not be unique to the context assignment function, either...

@boneskull boneskull added the bug label Jun 7, 2024
@boneskull boneskull changed the title Bug: error thrown from initial context assignment thrown too much Bug: toPromise leaks errors if actor has no subscriber w/ error listener Jun 7, 2024
@boneskull
Copy link
Contributor Author

I updated the title to better describe what I think is happening; my hunch is that it can be fixed in the toPromise implementation.

@boneskull
Copy link
Contributor Author

It may be worth mentioning why the context assignment function would ever want to throw. Because:

const machine = setup({
  types: {
    input: {} as {signal: AbortSignal}
  }
}).createMachine({
  context: ({input: {signal}}) => {
    if (signal.aborted) {
      throw new AbortError(signal.reason);
    }
  }
});

@Andarist
Copy link
Member

Andarist commented Jun 7, 2024

I updated the title to better describe what I think is happening; my hunch is that it can be fixed in the toPromise implementation.

It can't be fixed there. This error is thrown synchronously. By the time toPromise gets actually called in this example... it's already too late.

You can (likely) work around this problem like this:

const actor = createActor(TestMachine);
const p = toPromise(actor);
actor.start();
await p;

@boneskull
Copy link
Contributor Author

boneskull commented Jun 7, 2024

@Andarist Not sure I love it, but I can confirm that workaround works: https://stackblitz.com/edit/github-trfney-kh9qur?file=src%2Fmain.ts

Might want to update the docs and/or add a warning about this if you don't plan to address it otherwise 😄

@Andarist
Copy link
Member

Andarist commented Jun 7, 2024

To be clear, I don't necessarily say I love it either 😉

@boneskull boneskull changed the title Bug: toPromise leaks errors if actor has no subscriber w/ error listener Bug: actors leak uncaught errors Jun 7, 2024
@boneskull
Copy link
Contributor Author

boneskull commented Aug 16, 2024

@Andarist

It's not thrown synchronously, though:

export function reportUnhandledError(err: unknown) {
setTimeout(() => {
throw err;
});
}

UPDATE: nevermind; this doesn't work

Looking at this further, I think it could be solved by modifying Actor._error() and Actor._reportError() so that they accept a flag parameter. _error() passes the flag through to _reportError(). _reportError() then re-throws the error if the flag is true:

  private _error(err: unknown, sync = false): void {
    this._stopProcedure();
    this._reportError(err, sync);
    // ...
  }
  
  private _reportError(err: unknown, sync = false): void {
    if (!this.observers.size) {
      if (!this._parent) {
        if (sync) {
          throw err;
        }
        reportUnhandledError(err);
      }
      return;
    }
    // ...
  }  

Back in Actor.start():

case 'error':
this._error((this._snapshot as any).error);
return this;

The error case can be changed to this:

      case 'error':
        this._error((this._snapshot as any).error, true);
        return this;

It's not an elegant API by any means, but will only throw synchronously if the actor is already in an error state at startup and there are no active subscriptions.

@boneskull
Copy link
Contributor Author

Another way to solve it may be to create a transient, internal error subscription at the beginning of Actor.start() (if no other subscribers exist) which synchronously re-throws. The subscription would unsubscribe when start() exits.

@boneskull
Copy link
Contributor Author

boneskull commented Aug 17, 2024

The second suggestion works without gumming up existing error handling:

      case 'error':
        let err: unknown | undefined;
        let tempSub: Subscription | undefined;
        if (!this.observers.size) {
          tempSub = this.subscribe({error: error => {
            // avoid the thing that catches errors thrown from error observers
            err = error;
          }});
        }
        this._error((this._snapshot as any).error);
        tempSub?.unsubscribe();
        if (err) {
          throw err;
        }
        return this;
    }

and:

  it('throws errors synchronously if no observers are present', () => {
    const machine = createMachine({
      context: () => { 
        throw new Error('oh no'); 
      }
    });

    const actor = createActor(machine);
    expect(() => actor.start()).toThrowErrorMatchingInlineSnapshot(`"oh no"`);
  })

boneskull added a commit to boneskull/xstate that referenced this issue Aug 17, 2024
This modifies the error-handling logic for when an error is thrown during the creation of an Actor's initial snapshot (during `start()`).  _If_ the actor has _no_ observers, the error will now be thrown synchronously out of `start()` instead of to the global error handler.

Example use case:

```js

const actor = createBrokenActor();
const machine = createMachine({
  context: () => {
    throw new Error('egad!');
  }
});

try {
  await toPromise(actor.start());
} catch (err) {
  err.message === 'egad!' // true
}
```

Fixes: statelyai#4928
boneskull added a commit to boneskull/xstate that referenced this issue Aug 17, 2024
This modifies the error-handling logic for when an error is thrown during the creation of an Actor's initial snapshot (during `start()`).  _If_ the actor has _no_ observers, the error will now be thrown synchronously out of `start()` instead of to the global error handler.

Example use case:

```js
const machine = createMachine({
  context: () => {
    throw new Error('egad!');
  }
});

const actor = createActor(machine);

try {
  await toPromise(actor.start());
} catch (err) {
  err.message === 'egad!' // true
}
```

Note that this _does not impact child actors_.

Fixes: statelyai#4928
@matheo
Copy link

matheo commented Oct 10, 2024

@Andarist I'm having a leaked error with a spawned actor (I've tried fromObservable or fromCallback assigned childs). I'm trying to make a http request and if it throws, my main state-machine exits with an undefined event 😢

myState: {
    entry: [
        assign({
            registerAssign: ({ spawn }) => spawn('registerAssignActor', { id: 'register-assign' }),
        }),
    ],
    exit: [
        stopChild('register-assign'),
        assign({ registerAssign: registerAssignRef }),
    ],

I'm able to catch the error event with the wildcard on: '*' but the event is undefined on the parent machine.

@davidkpiano
Copy link
Member

davidkpiano commented Oct 10, 2024

@Andarist I'm having a leaked error with a spawned actor, no matter if I use fromObservable or fromCallback, I'm trying to make a http request and if it throws, my main state-machine exits with an undefined event 😢

Are you adding an error handler?

actor.subscribe({
  error: (err) => {
    // handle error
  }
});

actor.start();

@matheo
Copy link

matheo commented Oct 10, 2024

@davidkpiano I'm spawning the actor by reference and assigning it to the context.

@Andarist
Copy link
Member

@matheo please share a runnable repro case of the problem. It's hard to tell what you are observing based on a code slice.

@matheo
Copy link

matheo commented Oct 13, 2024

@Andarist thanks!
while building the repro I've realized an error displaying the child actor state,
so the undefined event was not a big deal because it doesn't trigger anything
and maybe it was related to the child actor being stopped/cleaned up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants