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

fix(core): errored initial snapshot throws sync w/o observers #5045

Closed
wants to merge 1 commit into from

Conversation

boneskull
Copy link
Contributor

@boneskull boneskull commented 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:

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, but it might want to!

Fixes: #4928

Copy link

changeset-bot bot commented Aug 17, 2024

⚠️ No Changeset found

Latest commit: 0dabaa7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@boneskull
Copy link
Contributor Author

boneskull commented Aug 17, 2024

For reference, the current workaround is this:

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

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

@boneskull
Copy link
Contributor Author

I don't know if the new behavior is desirable to the maintainers, but I find it more ergonomic. Thought I would restart that conversation here!

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
Copy link
Member

@davidkpiano davidkpiano left a comment

Choose a reason for hiding this comment

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

I agree with this PR in principle; waiting to see what @Andarist thinks

@boneskull
Copy link
Contributor Author

boneskull commented Aug 18, 2024

My main concern is that it is somewhat inconsistent, since if the example machine was spawned as a child actor, it would reach the global error handler.

...that being said, if using spawnChild(), there's really nowhere else for the error to go.

@boneskull
Copy link
Contributor Author

This looks like a breaking change to me unless you feel this is actually a "bug fix" and not just a "revision of intent" 😜

@Andarist
Copy link
Member

...that being said, if using spawnChild(), there's really nowhere else for the error to go.

Could you show the exact situation you are talking about here?

@boneskull
Copy link
Contributor Author

boneskull commented Aug 19, 2024

...that being said, if using spawnChild(), there's really nowhere else for the error to go.

Could you show the exact situation you are talking about here?

@Andarist

something like this:

it('handled sync errors thrown when starting a child actor should be reported globally when spawned via spawnChild()', (done) => {
  const badMachine = createMachine({
    context: () => {
      throw new Error('handled_sync_error_in_actor_start');
    }
  });
  
  const machine = createMachine({
    entry: [
      spawnChild(badMachine)
    ]
  });

  const actorRef = createActor(machine);
  actorRef.start();
  installGlobalOnErrorHandler((ev) => {
    ev.preventDefault();
    expect(ev.error.message).toEqual('handled_sync_error_in_actor_start');
    done();
  });
});

If a xstate.error.actor.badMachine (or w/e) event handler in machine is the canonical way to trap this error, then I don't think changes to the above behavior are warranted (i.e. changing it so the error is thrown out of actor.start(), which I think is only possible because it's thrown synchronously?). It's just that there's no other reasonable way to catch it out of the root actor (e.g. if badMachine was the root actor), which is what this PR solves.

Comment on lines +496 to +498
// **if the actor has no observer when start() is called**, this error would otherwise be
// thrown to the global error handler. to prevent this--and allow start() to be wrapped in try/catch--we
// create a temporary observer that receives the error (via _reportError()) and rethrows it from this call stack.
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if we can do this a different way without creating a temp sub

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@davidkpiano The folded section in this comment has an alternate proposal, which I explored but didn't get working fully.

Comment on lines +917 to +932
it('error thrown when resolving the initial context should reject when wrapped in a Promise', async () => {
const machine = createMachine({
context: () => {
throw new Error('oh no');
}
});

const actor = createActor(machine);

try {
await toPromise(actor.start());
fail();
} catch (err) {
expect((err as Error).message).toEqual('oh no');
}
});
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't work like you think it does. This error is still very much thrown synchronously. toPromise isn't ever called during this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure what you mean. If createActor threw synchronously, wouldn't this test fail?

Copy link
Member

Choose a reason for hiding this comment

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

IIRC, It's the actor.start that throws, before its return value can even get passed to toPromise

Comment on lines +897 to +910
it('error thrown when resolving the initial context should rethrow synchronously', (done) => {
const machine = createMachine({
context: () => {
throw new Error('oh no');
}
});

const actor = createActor(machine);

installGlobalOnErrorHandler(() => {
done.fail();
});

expect(() => actor.start()).toThrowErrorMatchingInlineSnapshot(`"oh no"`);
Copy link
Member

Choose a reason for hiding this comment

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

actors are always async conceptually, a synchronous throw is something that might sound convenient at first but you essentially create 2 error channels by introducing this. Now, if you want to handle errors, u need to both install an error listener and wrap this in try/catch. The guiding principle that we had when designing this was that you should always be notified about errors in the very same way (through error listeners).

Copy link
Member

Choose a reason for hiding this comment

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

@Andarist In theory I agree, but in practice, if we know that an actor is going to fail right at the start, rather than later during event processing, it's a much better DX (IMO) to throw right away.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is my feeling too. User experience > theoretical purity

Copy link
Member

Choose a reason for hiding this comment

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

@boneskull I may have changed my stance on this: https://www.youtube.com/watch?v=JCXZhe6KsxQ&t=294s

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't follow

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@davidkpiano Should I close this?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, let's close it for now, given that it's a breaking change.

@davidkpiano davidkpiano closed this Oct 7, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Bug: actors leak uncaught errors
3 participants