[fix] Add AbortController for async upload operations#12539
[fix] Add AbortController for async upload operations#12539sfc-gh-nbellante merged 4 commits intodevelopfrom
Conversation
🎉 Snyk checks have passed. No issues have been found so far.✅ security/snyk check is complete. No issues have been found. (View Details) ✅ license/snyk check is complete. No issues have been found. (View Details) |
✅ PR preview is ready!
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
There was a problem hiding this comment.
Pull Request Overview
This PR adds AbortController support to the AudioInput component to prevent race conditions and ensure proper cleanup of async upload operations. The implementation cancels previous uploads when new ones start and cleans up ongoing uploads when the component unmounts.
Key changes:
- Implements AbortController pattern for upload operation management
- Adds abort checks at critical points in the upload pipeline
- Provides cleanup mechanism on component unmount
| if (!abortController.signal.aborted) { | ||
| setIsError(true) | ||
| } |
There was a problem hiding this comment.
[nitpick] The abort check pattern if (!abortController.signal.aborted) is repeated multiple times throughout the function. Consider extracting this into a helper function like shouldProceed() or isNotAborted() to reduce duplication and improve maintainability.
There was a problem hiding this comment.
Meh, idk if I agree with this.
| if (notNullOrUndefined(widgetFormId)) | ||
| widgetMgr.setFormsWithUploadsInProgress(new Set()) | ||
| setIsUploading(false) | ||
| if (!abortController.signal.aborted) { | ||
| setIsUploading(false) | ||
| } |
There was a problem hiding this comment.
The widgetMgr.setFormsWithUploadsInProgress(new Set()) call on line 265 should also be conditional on abort status. If the operation was aborted, the form upload state cleanup might not be appropriate or could interfere with a new ongoing upload.
There was a problem hiding this comment.
The widget manager lives outside of the scope of this component, so I think this should be fine to have this check unguarded.
📉 Frontend coverage change detectedThe frontend unit test (vitest) coverage has decreased by 0.0700%
💡 Consider adding more unit tests to maintain or improve coverage. |
2f4650d to
862e096
Compare
862e096 to
8f0bfeb
Compare
b22b238 to
66ea0df
Compare
8f0bfeb to
0d8f61d
Compare
0d8f61d to
06d4203
Compare
66ea0df to
97bd3d5
Compare
06d4203 to
f49c9f8
Compare
b337f39 to
178e2ce
Compare
| // All uploads except the last should be aborted | ||
| const abortedCount = uploadResults.filter(r => r.aborted).length | ||
| expect(abortedCount).toBeGreaterThanOrEqual(0) |
There was a problem hiding this comment.
The assertion expect(abortedCount).toBeGreaterThanOrEqual(0) doesn't effectively test the abort functionality since it would pass even if no uploads were aborted. Since the test comment states "All uploads except the last should be aborted", a more precise assertion would be expect(abortedCount).toBe(uploadCount - 1) to verify exactly the expected number of aborted uploads.
| // All uploads except the last should be aborted | |
| const abortedCount = uploadResults.filter(r => r.aborted).length | |
| expect(abortedCount).toBeGreaterThanOrEqual(0) | |
| // All uploads except the last should be aborted | |
| const abortedCount = uploadResults.filter(r => r.aborted).length | |
| expect(abortedCount).toBe(uploadResults.length - 1) |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
frontend/lib/src/components/widgets/AudioInput/AudioInput.test.tsx
Outdated
Show resolved
Hide resolved
2fc9626 to
d1a5f07
Compare
d1a5f07 to
d29c2d1
Compare
| expect(audio_input.get_by_role("button", name="Stop recording")).to_be_visible() | ||
|
|
||
| # Let it record briefly | ||
| app.wait_for_timeout(500) # This is OK - we need actual recording time |
There was a problem hiding this comment.
The Streamlit E2E Tests guide states "If expect is insufficient, use the wait_until utility. Never use wait_for_timeout." This line uses app.wait_for_timeout(500) which violates this guideline. Replace the fixed timeout with wait_until and a condition that verifies the recording state has changed appropriately.
| app.wait_for_timeout(500) # This is OK - we need actual recording time | |
| app.wait_until(lambda: app.get_by_test_id("stAudioInput-recording-status").inner_text() == "Recording...") |
Spotted by Diamond (based on custom rule: E2E Playwright Tests)
Is this helpful? React 👍 or 👎 to let us know.
| assert len(tracking["created"]) >= 3, ( | ||
| f"Expected at least 3 blob URLs created, got {len(tracking['created'])}" | ||
| ) |
There was a problem hiding this comment.
Replace Python's assert statement with Playwright's expect assertion to reduce test flakiness, as specified in the Streamlit E2E Tests guide. Use expect(len(tracking["created"])).to_be_greater_than_or_equal(3) instead of the current assertion.
| assert len(tracking["created"]) >= 3, ( | |
| f"Expected at least 3 blob URLs created, got {len(tracking['created'])}" | |
| ) | |
| expect(len(tracking["created"])).to_be_greater_than_or_equal(3) |
Spotted by Diamond (based on custom rule: E2E Playwright Tests)
Is this helpful? React 👍 or 👎 to let us know.
- Add uploadAbortControllerRef to track active uploads - Cancel previous uploads when starting new ones - Check abort signal before state updates - Clean up abort controller on component unmount This prevents state updates after unmount and properly cancels in-flight upload operations when the component unmounts or a new recording starts.
- Pass AbortSignal from AudioInput to uploadFiles utility - Update uploadFiles to accept and forward AbortSignal to uploadClient - Add E2E tests to verify rapid re-recordings work without race conditions - Add test for blob URL cleanup to prevent memory leaks This ensures that when a new recording starts, the previous upload is properly aborted, preventing race conditions where an old recording could overwrite a newer one.
- Remove wait_for_timeout(2000) and replace with proper expect conditions - Use expect().to_be_visible() for waiting on elements (built-in waiting) - Keep wait_for_timeout only for actual recording duration (unavoidable) - Add proper wait conditions between recordings using expect() - Follow Streamlit E2E test guide recommendation to avoid wait_for_timeout
b7c07d0 to
6adf1d2
Compare
| # Verify that earlier created URLs were revoked | ||
| for i in range(min(2, len(tracking["created"]) - 1)): | ||
| url = tracking["created"][i] | ||
| assert url in tracking["revoked"], f"Blob URL {url} should have been revoked" |
There was a problem hiding this comment.
The Streamlit E2E Tests guide states 'Use expect for assertions, not assert (reduces flakiness)'. This assertion uses Python's built-in assert statement instead of Playwright's expect. Replace with expect() for better test reliability and consistency with the testing framework.
| assert url in tracking["revoked"], f"Blob URL {url} should have been revoked" | |
| expect(lambda: url in tracking["revoked"]).to_be_truthy(f"Blob URL {url} should have been revoked") |
Spotted by Diamond (based on custom rule: E2E Playwright Tests)
Is this helpful? React 👍 or 👎 to let us know.
- Use wait_until pattern for async checks before assertions - Keep assert statements for non-DOM values as per codebase conventions - Add comments explaining why asserts are acceptable for JavaScript evaluations - This aligns with patterns used in st_heading_test.py and other tests
f084853 to
de234dd
Compare
| assert len(tracking["created"]) >= 3, ( | ||
| f"Expected at least 3 blob URLs created, got {len(tracking['created'])}" | ||
| ) | ||
|
|
||
| assert len(tracking["revoked"]) >= 2, ( | ||
| f"Expected at least 2 blob URLs revoked, got {len(tracking['revoked'])}" | ||
| ) |
There was a problem hiding this comment.
The Streamlit E2E Tests guide states 'Use expect for assertions, not assert (reduces flakiness)'. These lines use Python assert statements instead of Playwright's expect. Replace these assert statements with appropriate expect assertions to follow the testing best practices and reduce test flakiness.
| assert len(tracking["created"]) >= 3, ( | |
| f"Expected at least 3 blob URLs created, got {len(tracking['created'])}" | |
| ) | |
| assert len(tracking["revoked"]) >= 2, ( | |
| f"Expected at least 2 blob URLs revoked, got {len(tracking['revoked'])}" | |
| ) | |
| expect(lambda: len(tracking["created"]) >= 3).to_be_truthy( | |
| f"Expected at least 3 blob URLs created, got {len(tracking['created'])}" | |
| ) | |
| expect(lambda: len(tracking["revoked"]) >= 2).to_be_truthy( | |
| f"Expected at least 2 blob URLs revoked, got {len(tracking['revoked'])}" | |
| ) |
Spotted by Diamond (based on custom rule: E2E Playwright Tests)
Is this helpful? React 👍 or 👎 to let us know.

Describe your changes
This pull request improves the robustness of the
AudioInputcomponent by adding support for aborting ongoing audio uploads. This ensures that if a new upload is started or the component is unmounted, any previous or ongoing upload is properly cancelled, preventing race conditions and unnecessary state updates. (And the "tried to update state on an unmounted component" error.)GitHub Issue Link (if applicable)
No filed issue.
Testing Plan
We could I guess try to repro this behavior in the tests, but since this is a preventative measure, I don't think the ROI is worth adding a test for this.
Contribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.