Skip to content

Commit 9ad6b0d

Browse files
committed
fix: wait for abortPendingRequest before inserting new request entry
1 parent 332b0b7 commit 9ad6b0d

File tree

3 files changed

+60
-39
lines changed

3 files changed

+60
-39
lines changed

packages/shield-controller/src/backend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
142142
};
143143

144144
// clean up the pending coverage result polling
145-
this.#pollingWithTimeout.abortPendingRequest(req.signatureRequest.id);
145+
await this.#pollingWithTimeout.abortPendingRequest(req.signatureRequest.id);
146146

147147
const res = await this.#fetch(
148148
`${this.#baseUrl}/v1/signature/coverage/log`,
@@ -166,7 +166,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
166166
};
167167

168168
// clean up the pending coverage result polling
169-
this.#pollingWithTimeout.abortPendingRequest(req.txMeta.id);
169+
await this.#pollingWithTimeout.abortPendingRequest(req.txMeta.id);
170170

171171
const res = await this.#fetch(
172172
`${this.#baseUrl}/v1/transaction/coverage/log`,

packages/shield-controller/src/polling-with-timeout-abort.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,30 @@ describe('PollingWithTimeoutAndAbort', () => {
9999
fnName: 'test',
100100
});
101101
await delay(15); // small delay to let the request start
102-
pollingWithTimeout.abortPendingRequest('test');
102+
await pollingWithTimeout.abortPendingRequest('test');
103103
await expect(request).rejects.toThrow('test: Request cancelled');
104104
});
105+
106+
it('should get the result when the request succeeds', async () => {
107+
const pollingWithTimeout = new PollingWithTimeoutAndAbort({
108+
timeout: 1000,
109+
pollInterval: 20,
110+
});
111+
112+
const requestFn = jest
113+
.fn()
114+
.mockImplementation(async (_signal: AbortSignal) => {
115+
return new Promise((resolve) => {
116+
setTimeout(() => {
117+
resolve('test result');
118+
}, 100);
119+
});
120+
});
121+
122+
const request = pollingWithTimeout.pollRequest('test', requestFn, {
123+
fnName: 'test',
124+
});
125+
const result = await request;
126+
expect(result).toBe('test result');
127+
});
105128
});

packages/shield-controller/src/polling-with-timeout-abort.ts

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,17 @@ export class PollingWithTimeoutAndAbort {
5050
const pollInterval = pollingOptions.pollInterval ?? this.#pollInterval;
5151

5252
// clean up the request entry if it exists
53-
this.abortPendingRequest(requestId);
53+
await this.abortPendingRequest(requestId);
5454

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

5858
while (!abortController.signal.aborted) {
5959
try {
6060
const result = await requestFn(abortController.signal);
61-
// polling success, we just need to clean up the request entry and return the result
62-
this.#cleanUpOnFinished(requestId);
61+
// polling success, we just need to abort the request and return the result
62+
// note: this will trigger the abort handler, which will clean up the request entry
63+
abortController.abort();
6364
return result;
6465
} catch {
6566
if (abortController.signal.aborted) {
@@ -82,19 +83,30 @@ export class PollingWithTimeoutAndAbort {
8283
}
8384

8485
/**
85-
* Abort the pending requests.
86+
* Abort the pending request.
8687
* This will clean up the request entry if it exists, and abort the pending request if it exists.
88+
* Note: this needs to be called before making a new request (with await) to avoid race conditions with the new request.
8789
*
8890
* @param requestId - The ID of the request to abort.
91+
* @returns A promise that resolves when the request is aborted.
8992
*/
90-
abortPendingRequest(requestId: string) {
91-
// firstly clean up the request entry if it exists
92-
// note: this does not abort the request, it only cleans up the request entry for the next polling cycle
93-
const existingEntry = this.#cleanUpRequestEntryIfExists(requestId);
94-
// then abort the request if it exists
95-
// note: this does abort the request, but it will not trigger the abort handler (hence, {@link cleanUpRequestEntryIfExists} will not be called)
96-
// coz the AbortHandler event listener is already removed from the AbortSignal
97-
existingEntry?.abortController.abort(this.ABORT_REASON_CANCELLED);
93+
async abortPendingRequest(requestId: string) {
94+
return new Promise((resolve) => {
95+
// firstly clean up the request entry if it exists
96+
// note: this does not abort the request, it only cleans up the request entry from the map.
97+
const removedEntry = this.#cleanUp(requestId);
98+
// if there's no existing entry, then we can just resolve the promise
99+
if (!removedEntry) {
100+
resolve(undefined);
101+
return;
102+
}
103+
// otherwise, we will abort the existing/pending request
104+
removedEntry.abortController.abort(this.ABORT_REASON_CANCELLED);
105+
// we want to make sure that the request is actually aborted, before inserting the new request entry
106+
if (removedEntry.abortController.signal.aborted) {
107+
resolve(undefined);
108+
}
109+
});
98110
}
99111

100112
/**
@@ -115,7 +127,7 @@ export class PollingWithTimeoutAndAbort {
115127

116128
// Set the abort handler and listen to the `abort` event
117129
const abortHandler = () => {
118-
this.#cleanUpOnFinished(requestId);
130+
this.#cleanUp(requestId);
119131
};
120132
abortController.signal.addEventListener('abort', abortHandler);
121133

@@ -132,35 +144,21 @@ export class PollingWithTimeoutAndAbort {
132144
}
133145

134146
/**
135-
* Clean up the request entry upon finished (success or failure).
136-
* This will remove the abort handler from the AbortSignal, clear the timeout, and remove the request entry.
147+
* Clean up the request entry.
148+
* This will clear the timeout, remove the abort handler event listener, and remove the request entry from the map.
137149
*
138-
* @param requestId - The ID of the request to clean up for.
139-
* @returns The request entry that was cleaned up, if it exists.
150+
* @param requestId - The ID of the request to clean up.
151+
* @returns The request entry that was cleaned up.
140152
*/
141-
#cleanUpOnFinished(requestId: string): RequestEntry | undefined {
142-
const requestEntry = this.#cleanUpRequestEntryIfExists(requestId);
153+
#cleanUp(requestId: string) {
154+
const requestEntry = this.#requestEntries.get(requestId);
143155
if (requestEntry) {
156+
clearTimeout(requestEntry.timerId); // clear the timeout
144157
requestEntry.abortController.signal.removeEventListener(
145158
'abort',
146159
requestEntry.abortHandler,
147-
);
148-
}
149-
return requestEntry;
150-
}
151-
152-
/**
153-
* Clean up the request entry if it exists.
154-
* This will clear the pending timeout, remove the event listener from the AbortSignal, and remove the request entry.
155-
*
156-
* @param requestId - The ID of the request to handle the abort for.
157-
* @returns The request entry that was aborted, if it exists.
158-
*/
159-
#cleanUpRequestEntryIfExists(requestId: string): RequestEntry | undefined {
160-
const requestEntry = this.#requestEntries.get(requestId);
161-
if (requestEntry) {
162-
clearTimeout(requestEntry.timerId); // Clear the timeout
163-
this.#requestEntries.delete(requestId); // Remove the request entry
160+
); // clean up the abort handler event listener
161+
this.#requestEntries.delete(requestId); // remove the request entry
164162
}
165163
return requestEntry;
166164
}

0 commit comments

Comments
 (0)