Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Fixed
- Fixed hanging `getLatestBlock()` promises when block tracker is stopped before request completion ([#320](https://github.com/MetaMask/eth-block-tracker/pull/320))
- Pending `getLatestBlock()` requests are now properly rejected with "Block tracker destroyed" error when the tracker is stopped

## [12.0.0]
### Changed
Expand Down
81 changes: 81 additions & 0 deletions src/PollingBlockTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@

await withPollingBlockTracker(async ({ blockTracker }) => {
expect(blockTracker.isRunning()).toBe(false);
blockTracker.getLatestBlock();

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test (18.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test (20.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 177 in src/PollingBlockTracker.test.ts

View workflow job for this annotation

GitHub Actions / Build, Lint, and Test (22.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
expect(blockTracker.isRunning()).toBe(false);
});
});
Expand Down Expand Up @@ -821,6 +821,87 @@
);
});
});

it('should reject pending latest block request if block tracker is stopped before fetch completes on second getLatestBlock call', async () => {
const setTimeoutRecorder = recordCallsToSetTimeout();
const blockTrackerOptions = {
pollingInterval: 100,
blockResetDuration: 200,
};

await withPollingBlockTracker(
{
provider: {
stubs: [
{
methodName: 'eth_blockNumber',
result: '0x0',
},
{
methodName: 'eth_blockNumber',
result: '0x0',
},
],
},
blockTracker: blockTrackerOptions,
},
async ({ blockTracker }) => {
// Step 1: Start the block tracker
blockTracker.on('latest', EMPTY_FUNCTION);

// Step 2: Wait for the first block update to resolve
await new Promise((resolve) => {
blockTracker.on('sync', resolve);
});
expect(blockTracker.getCurrentBlock()).toBe('0x0');
expect(blockTracker.isRunning()).toBe(true);

// Clear the current block to force a new request for the next getLatestBlock
// When the block tracker stops, there may be two `setTimeout`s in
// play: one to go to the next iteration of the block tracker
// loop, another to expire the current block number cache. We don't
// know which one has been added first, so we have to find it.
blockTracker.removeAllListeners();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I had to restart the block tracker to clear the cached currentBlock.

await setTimeoutRecorder.nextMatchingDuration(
blockTrackerOptions.blockResetDuration,
);
expect(blockTracker.getCurrentBlock()).toBeNull();

// Restart the tracker for the second call
blockTracker.on('latest', EMPTY_FUNCTION);

// Step 3: Immediately after, call getLatestBlock
const secondBlockPromise = blockTracker.getLatestBlock();

// Step 4: Immediately after, stop the block tracker
blockTracker.removeAllListeners();

// Verify block tracker state
expect(blockTracker.isRunning()).toBe(false);
expect(blockTracker.getCurrentBlock()).toBeNull();

// The call to getLatestBlock would then never resolve (should be rejected)
await expect(secondBlockPromise).rejects.toThrow(
'Block tracker destroyed',
);

// Verify that the block reset timeout is set up
expect(
setTimeoutRecorder.calls.some((call) => {
return call.duration === blockTrackerOptions.blockResetDuration;
}),
).toBe(true);

// Wait for the block reset timeout to complete
await setTimeoutRecorder.nextMatchingDuration(
blockTrackerOptions.blockResetDuration,
);

// Verify that the current block is still null after the timeout
expect(blockTracker.getCurrentBlock()).toBeNull();
Comment on lines +888 to +901
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not really tied to the test and thus can be removed

},
);
});
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/PollingBlockTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,8 @@ export class PollingBlockTracker

async destroy() {
this._cancelBlockResetTimeout();
this._maybeEnd();
super.removeAllListeners();
this.#rejectPendingLatestBlock(new Error('Block tracker destroyed'));
this._maybeEnd();
}

isRunning(): boolean {
Expand Down Expand Up @@ -217,6 +216,7 @@ export class PollingBlockTracker
this._isRunning = false;
this._setupBlockResetTimeout();
this._end();
this.#rejectPendingLatestBlock(new Error('Block tracker destroyed'));
this.emit('_ended');
}

Expand Down
Loading