Skip to content

Conversation

lwin-kyaw
Copy link
Contributor

@lwin-kyaw lwin-kyaw commented Oct 15, 2025

Explanation

This PR includes ~

  • Updated internal coverage result polling and log logic.
    • Added cancellation logic to the polling.
    • Updated implementation of timeout.
    • Cancel any pending requests before starting new polling or logging.
  • Updated TransactionMeta comparison in TransactionController:stateChange subscriber to avoid triggering multiple check coverage result unnecessarily.
  • Removed Personal Sign check from the check signature coverage result.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

Note

Implements cancellable/timeout-aware coverage result polling, cancels pending requests before new polls/logs, refines tx simulation change detection, and removes the personal_sign-only guard for signature coverage; updates tests and changelog.

  • Backend:
    • Introduces PollingWithTimeoutAndAbort for coverage result polling with timeout, abort, and per-request IDs.
    • Reworks #getCoverageResult to use abort signals; throws on non-200; updates timeout error message.
    • Cancels pending polls before logSignature/logTransaction.
  • Controller:
    • Removes personal_sign restriction; check signature coverage for any new SignatureRequest.
    • Adds #compareTransactionSimulationData and uses it to re-check coverage when simulation data meaningfully changes.
  • Tests:
    • Adds unit tests for polling utility and simulation-data change scenarios; adjusts backend tests (timeout message, cleanup).
  • Docs:
    • Updates CHANGELOG.md with polling/logging changes, refined tx comparison, and signature-check note.

Written by Cursor Bugbot for commit a5295ab. This will update automatically on new commits. Configure here.

@lwin-kyaw lwin-kyaw requested a review from a team as a code owner October 15, 2025 21:46
cursor[bot]

This comment was marked as outdated.

@lwin-kyaw
Copy link
Contributor Author

@metamaskbot publish-preview

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@lwin-kyaw lwin-kyaw requested a review from a team as a code owner October 16, 2025 04:51
@lwin-kyaw
Copy link
Contributor Author

@metamaskbot publish-preview

Copy link
Contributor

@matthiasgeihs matthiasgeihs left a comment

Choose a reason for hiding this comment

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

Please note my comment on using a singular abort controller for unrelated result requests. I think it's highly problematic. You only want to cancel polling for that specific coverageId, not for all coverage requests.

const simulationDataChanged = this.#compareTransactionSimulationData(
previousTransaction?.simulationData,
transaction.simulationData,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Bot must have had a bad day. That's not the case, as far as I can see.

pollInterval?: number;
},
): Promise<GetCoverageResultResponse> {
if (this.#abortController && !this.#abortController.signal.aborted) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The way you integrated the abort functionality is problematic.

You use one abort controller for all requests.

There may be situations where you have result polling for different transactions and signatures at the same time. If one log request comes in, it cancels the polling from all other unrelated transactions and signatures. That's most definitely not what we want.

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 have updated the design with Map<transactionId | signatureId, AbortController to handle multiple requests.
9225267

Comment on lines 216 to 244
let shouldContinuePolling = true;
const poll = async (): Promise<GetCoverageResultResponse> => {
// Poll until the coverage result is ready or the abort signal is triggered.
while (shouldContinuePolling && !abortController.signal.aborted) {
const startTime = Date.now();
const res = await this.#fetch(configs.coverageResultUrl, {
method: 'POST',
headers,
body: JSON.stringify(reqBody),
signal: abortController.signal,
});
if (res.status === 200) {
shouldContinuePolling = false;
return (await res.json()) as GetCoverageResultResponse;
}
if (!abortController.signal.aborted) {
await sleep(pollInterval - (Date.now() - startTime));
}
// The following line will not have an effect as the upper level promise
// will already be rejected by now.
throw new Error('unexpected error');
};
}
// The following line will not have an effect as the upper level promise
// will already be rejected by now.
throw new Error('unexpected error');
};

poll().then(resolve).catch(reject);
});
return await withTimeoutAndCancellation<GetCoverageResultResponse>(
poll,
timeout,
abortController,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of rewriting the entire logic, you could have just used the existing variable timeoutReached (maybe rename it) to cancel the polling.

Copy link
Contributor

Choose a reason for hiding this comment

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

We should avoid refactoring existing logic, if not necessary. I suspect the logic might have been unclear and that's why you have rewritten it. Instead you could also check with the author to clarify the existing code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Restore the existing logic and appended the cancellation logic in this commit, 9225267

cursor[bot]

This comment was marked as outdated.

Comment on lines 222 to 234

const abortHandler = () => {
timeoutReached = true;
this.#removeAbortHandler(configs.requestId, abortHandler);
reject(new Error('Coverage result polling cancelled'));
};
abortController.signal.addEventListener('abort', abortHandler);

setTimeout(() => {
timeoutReached = true;
this.#removeAbortHandler(configs.requestId, abortHandler);
reject(new Error('Timeout waiting for coverage result'));
}, timeout);
Copy link
Contributor

Choose a reason for hiding this comment

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

We could remove some redundancy here. Would it be possible to have the cleanup and rejection in just one place? Maybe you could simplify call abortController.abort() when the timeout triggers?

        timeoutReached = true;
        this.#removeAbortHandler(configs.requestId, abortHandler);
        reject(new Error(msg));

Comment on lines 230 to 234
setTimeout(() => {
timeoutReached = true;
this.#removeAbortHandler(configs.requestId, abortHandler);
reject(new Error('Timeout waiting for coverage result'));
}, timeout);
Copy link
Contributor

Choose a reason for hiding this comment

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

Since you are already using an AbortSignal above, you could also use an AbortSignal.timeout here (instead of setTimeout).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please advise me if I'm wrong.

One problem with using AbortSignal.timeout is that, we can't manually cancel the timeout.
Let's say when another request comes in, we need to cancel the pending requests right?
Cancelling request needs two things here

  • cancel the abort controller (to stop polling)
  • cancel the timeout

For the timeout cancel, if we are using JS timers, we can simply cancel the timers. However, there's no way to cancel the AbortSignal.timeout, which will eventually throw which would be problematic, too.

method: 'POST',
headers,
body: JSON.stringify(reqBody),
signal: abortController.signal,
Copy link
Contributor

Choose a reason for hiding this comment

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

This does not respect the timeout.
You can have a look at my draft, how the two can be combined:

timeoutSignal.onabort = () => abortController.abort();

Similarly, the while condition does not respect the abortController.signal.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this doesn't respect the timeout. But this cancel the ongoing network request, when the abortController is aborted (aka new request is triggered).

To be frank, this (AbortController with network request) is a very common pattern (especially in frontend/react).
Ref: https://stackoverflow.com/a/61056569

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Similarly, the while condition does not respect the abortController.signal.

When abortController.signal is aborted, we set timeoutReached=true where break the while loop.

const abortController = this.#abortControllerMap.get(requestId);
if (abortController) {
if (abortHandler) {
abortController.signal.removeEventListener('abort', abortHandler);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering: Is it necessary to remove the handler, if we drop the whole controller anyways? (Might simplify a few things.)

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 think we should clean up (removeEventListener) whenever we do addEventListener.
Eventually, the event listeners will be clean up when the event target (AbortController) is garbage collected. But still memory leaks can if AbortController is not GC, due to some unknown reasons :)

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see any use of mocking the AbortController. On the downside, it bloats the codebase and can also cause some issues (e.g., it currently doesn't work with AbortSignal.any). We should remove this mock.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad on not removing the MockAbortController when it's no longer needed anymore.
I will take note of that, and will take a closer and more careful look in the future. 🙏

const coverageId = 'coverageId';

// Mock fetch responses
fetchMock.mockImplementation(
Copy link
Contributor

Choose a reason for hiding this comment

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

This mock implementation is in parts redundant to the other fetchMock implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these new tests were refactored and removed here, 565c42c

const signatureRequest = generateMockSignatureRequest();
const coverageId = 'test';

fetchMock.mockImplementation(
Copy link
Contributor

Choose a reason for hiding this comment

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

This mock implementation is in parts redundant to the other fetchMock implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these new tests were refactored and removed here, 565c42c

};
}

#cancelPreviousPendingRequests(id: string) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
#cancelPreviousPendingRequests(id: string) {
#cancelPendingRequests(id: string) {

If it's "pending" request, it's always a "previous" request. So we can omit previous.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these logics were refactored and removed here, 565c42c

coverageId,
};

const abortController = this.#assignNewAbortController(configs.requestId);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason why you create this here and not within the Promise?

(We could probably move all things from the outer context into the Promise. Might be cleaner overall.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these logics were refactored and removed here, 565c42c


const abortHandler = () => {
timeoutReached = true;
this.#removeAbortHandler(configs.requestId, abortHandler);
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated to here, but could we change configs to config? Configuration is usually used in singular. Alternatively, you could use opts or options.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All these logics were refactored and removed here, 565c42c

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@lwin-kyaw
Copy link
Contributor Author

@metamaskbot publish-preview

Comment on lines +234 to +237
this.#pollingWithTimeout
.pollRequest(config.requestId, requestCoverageFn, pollingOptions)
.then(resolve)
.catch(reject);
Copy link
Contributor

Choose a reason for hiding this comment

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

The refactor makes everything much more readable. 👍

Now you can even drop the wrapping new Promise and just write return await this.#pollingWithTimeout.pollRequest(...);.

throw new Error(`${pollingOptions.fnName}: Request cancelled`);
}
}
await this.#delay(pollInterval);
Copy link
Contributor

@matthiasgeihs matthiasgeihs Oct 17, 2025

Choose a reason for hiding this comment

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

Note that this is not canceled by the abort signal. So if we have a long polling interval, then it can take some time until this returns.
Previously this was achieved by rejecting immediately when the timeout occurred.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the abort doesn't cancelled the delay between polling.
But I don't think this is a big issue, a small delay won't hurt so much. After the delay, the polling will break.

try {
const result = await requestFn(abortController.signal);
// polling success, we just need to clean up the request entry and return the result
this.#cleanUpOnFinished(requestId);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you have to call this cleanup function also in the catch 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.

Not really, coz cleanUpOnFinished will be called in the AbortHandler and Timeout handle, where we only want to clean up.
We don't want to clean in case of normal errors, we want the polling to continue.

this.abortPendingRequests(requestId);

// insert the request entry for the next polling cycle
const { abortController } = this.#insertRequestEntry(requestId, timeout);
Copy link
Contributor

Choose a reason for hiding this comment

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

The whole functionality of this is a bit difficult to grasp as it's spread across the whole file and different handlers (especially the different cleanup handlers are confusing).

Would it be possible to streamline the logic a bit:

  1. Create 1 abort controller. If this aborts, the polling aborts and the abort handling is cleaned up.
  2. Attached the timeout event to the abort controller. (i.e., on timeout, call controller.abort())

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.

2 participants