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: internal listeners infinite retry loop #284

Open
wants to merge 14 commits into
base: main
Choose a base branch
from

Conversation

mikesposito
Copy link
Member

@mikesposito mikesposito commented Nov 13, 2024

Our block tracker implementation relies on the presence of listeners to establish whether the polling should be continued or stopped.

However, an internal listener is being added in the getLatestBlock method, which will count as the other external listeners and will prevent the instance from stopping fetching new blocks. This creates an infinite loop in case the network is unreachable, because of the retry mechanism.

This PR aims to fix this behavior by keeping track of internal listeners' references in order to exclude them from the listener's count

Fixes #163

@mikesposito mikesposito requested a review from a team as a code owner November 13, 2024 13:15
@mikesposito
Copy link
Member Author

It would be good to cover this with tests

this.#addInternalListener(onLatestBlockAvailable);
this.#addInternalListener(onLatestBlockUnavailable);
this.once('latest', onLatestBlockAvailable);
this.on('error', onLatestBlockUnavailable);
Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, I'm thinking that this would not cover the scenario where the block tracker is destroyed before fetching the first block and without throwing errors.

We can probably listen to _ended instead

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like _ended could present other issues, because of the weird order of events received:

  1. _start is emitted
  2. Latest block is fetched from the provider
  3. _ended event listener is executed
  4. latest event listener is executed

Copy link
Contributor

@mcmire mcmire Nov 22, 2024

Choose a reason for hiding this comment

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

If I understand correctly, because getLatestBlock now relies on latest or error to fire in order to resolve the promise it creates, if that never happens because the fetch call never happens, then getLatestBlock will just hang, even if destroy is called?

I think listening to _ended could make sense. Perhaps we add a check in onLatestBlockAvailable that only resolves the promise if the block runner is still running?

Copy link
Contributor

@mcmire mcmire Nov 22, 2024

Choose a reason for hiding this comment

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

I am slow today :/ It seems that Mark's solution in #286 may solve that case, because it grants destroy access to the promise that getLatestBlock creates, and we can just force-reject it if it hasn't resolved yet.

CHANGELOG.md Outdated Show resolved Hide resolved
src/PollingBlockTracker.ts Outdated Show resolved Hide resolved
src/PollingBlockTracker.ts Outdated Show resolved Hide resolved
src/PollingBlockTracker.ts Outdated Show resolved Hide resolved
@@ -89,7 +91,19 @@ export class PollingBlockTracker
async destroy() {
this._cancelBlockResetTimeout();
this._maybeEnd();
super.removeAllListeners();
this.eventNames().forEach((eventName) =>
Copy link
Contributor

@mcmire mcmire Nov 21, 2024

Choose a reason for hiding this comment

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

Do we want to explain why excluding internal listeners are necessary? Perhaps something like:

Suggested change
this.eventNames().forEach((eventName) =>
// The `getLatestBlock` method waits for the latest block to be fetched in
// the next iteration of the polling loop. It does this by listening to the
// `latest` event, and it handles errors that occur during fetching by
// listening to the `error` event. Because `getLatestBlock` is actively
// relying on these listeners to get called in order to resolve or reject a
// promise, we don't want to remove them (otherwise the polling loop will
// run forever and the promise will never get fulfilled). We will handle
// removing them manually.
this.eventNames().forEach((eventName) =>

Copy link
Contributor

@mcmire mcmire Nov 21, 2024

Choose a reason for hiding this comment

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

By the way, I was playing around with this PR and it seems that if you revert the changes to destroy and _getBlockTrackerEventCount, then your test still works. It seems that listening to error solved at least one bug. But perhaps there is another test we need to write to verify the endless loop bug? I can take another look tomorrow to see what that would be.

@@ -185,6 +185,33 @@ describe('PollingBlockTracker', () => {
);
});

it('should not retry failed requests after the block tracker is stopped', async () => {
Copy link
Contributor

@mcmire mcmire Nov 21, 2024

Choose a reason for hiding this comment

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

Hmm, this test does not seem to test that requests are not retried. I believe what it tests is that the promise that getLatestBlock returns eventually resolves or is rejected. Should we be more clear here? Something like:

Suggested change
it('should not retry failed requests after the block tracker is stopped', async () => {
it('should return a promise that rejects if the request for the block number fails and the block tracker is then stopped', async () => {

Or do we have a way to test that requests are not retried?

Copy link
Contributor

@mcmire mcmire Nov 22, 2024

Choose a reason for hiding this comment

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

Well, perhaps the original test name is fine. I guess the test would fail if the polling loop continued because we are using numAutomaticCalls: 1. Maybe the new test name I am suggesting is appropriate for the case you mentioned above, or something.

* Suggestion to simplify prevention of dangling Promise on destroy

The `fix/internal-listeners` branch has a number of changes intended to
ensure we don't have a dangling unresolved Promise when the block
tracker is destroyed. This solution involved adding an additional
listener to capture errors, and it involved not removing internal
listeners when `destroy` is called. This required changes to some
logging in `_updateAndQueue` as well.

This commit is an alternative solution that avoids the use of internal
listeners, thus avoiding much of the complexity in the previous
solution. Instead an internal deferred Promise is used. This also
might be slightly more efficient when `getLatestBlock` is called
repeatedly, as we can reuse the same listener rather than creating a
new one each time.

* Unset pending latest block after it has resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Endless failed request polling loop
3 participants