Fix cancelled HTTP requests showing as Pending in DevTools Network tab#9685
Fix cancelled HTTP requests showing as Pending in DevTools Network tab#9685rishika0212 wants to merge 14 commits intoflutter:masterfrom
Conversation
srawlins
left a comment
There was a problem hiding this comment.
This code looks great; thanks for the PR! I'd like to test manually before landing.
| super.shrinkWrap, | ||
| super.padding, | ||
| super.scrollCacheExtent, | ||
| super.cacheExtent, |
There was a problem hiding this comment.
CC @elliette ; from the PR description:
Update CustomPointerScrollView to use cacheExtent so the project compiles with the current Flutter SDK
packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
Show resolved
Hide resolved
|
Hi @rishika0212 sorry for the delay. I've been manually testing this and I have a clarifying question: I use the devtools_companion app to test the Network panel. I've got a PR that adds "cancellation" support: elliette/devtools_companion#23. I've observed behavior that seems questionable to me, but you might shed some light. Take this series of steps:
After these steps, I do not see the Cancelled status (it seems to remain Open). However, if the server never starts to send a response (the "Response Body checkbox is not checked), then the Cancelled status appears as expected. I think the Cancelled status should still appear, even if the server has started to respond. But I am not an HTTP expert. WDYT? |
|
Oh, additionally, the other problem I observed is that the table will always show the Cancelled status before a server completes its response, even if the request was not in fact cancelled. I'll double check when this behavior occurs, but I observed it in the DevTools Companion app. |
|
Hey @srawlins thanks again for the detailed repro, this was super helpful. I ran through it carefully using devtools_companion (including your srawlins/cancel case) and your exact sequence. Here’s what I’m seeing:
I also tightened up the DevTools logic a bit to avoid showing “Cancelled” too early before a response actually completes. For the specific edge case you mentioned (response starts, then gets cancelled after ~1s): However, from what I can tell, this case doesn’t always come through with a clear cancellation signal in the HTTP profile data. Sometimes it still ends up looking like a normal 200 style response, which makes it tricky for DevTools to classify it confidently based on the current fields. So overall from what I can tell…
Let me know if you’re seeing anything different on your side! |
Do you mean you've done this before today, or you will upload a commit? |
Before your comment, I hadn’t specifically tested the “cancel before complete” path in this depth, but after your note I ran those scenarios in devtools_companion, improved the classification logic, and added tests. I can push the tested improvements now (mainly the cancelled vs pending behavior). The “response already started, then cancelled” case is still not fully reliable yet since the profiling signal is ambiguous in some runs. |
|
Right now, in the “response started, then client cancels” case, the profile payload can sometimes look like a normal 200 response. When that happens, the UI doesn’t really have a reliable way to tell the difference between:
So unless we get a clear signal from upstream (like wasCancelled, a dedicated cancel event, or a specific error field), we’re basically guessing. And that guess can go wrong in both directions:
|
That would be great!
This sounds OK to me. |
I've pushed the updates. |
srawlins
left a comment
There was a problem hiding this comment.
Just a couple of small changes; thanks!
| 'cancel', | ||
| 'canceled', | ||
| 'cancelled', | ||
| 'operation canceled', |
There was a problem hiding this comment.
Since we are just searching in a String for these values, we either don't need the longer ones ("operation canceled") or we don't need the shorter ones ("canceled").
I think it is find to just keep the shorter ones.
Are these values derived from some general libraries, like the dio package and dart:io?
There was a problem hiding this comment.
Yes, they’re derived from cancellation wording observed in common Dart HTTP stacks, specifically our dart:io, package:http, and dio companion flows and the resulting VM profile payload strings.
| 'cancelled', | ||
| 'operation canceled', | ||
| 'operation cancelled', | ||
| 'abort', |
There was a problem hiding this comment.
I think we should remove the shortest two in this list, "cancel" and "abort." Unless there are examples from dart:io, package:http, or package:dio, where a cancelled error/event contains "cancel" or "abort" but not "cancelled"/"canceled" or "aborted."
There was a problem hiding this comment.
Thanks for the suggestions, I’ve updated things accordingly.
I kept just canceled, cancelled, and aborted, and removed the longer phrases like operation canceled / operation cancelled since we’re already doing substring matching. I also dropped the more generic terms like cancel and abort to avoid false positives.
These markers are based on the cancellation wording we’ve seen in related flows (like dart:io request.abort(), package:http client.close(), and dio cancelToken.cancel(...)) along with the profiler payload text.
One thing to note: in companion cancel mode, we can still get a 200 since the status gets set early so in those cases, classification relies on explicit cancellation text in the error/event payloads.
packages/devtools_app/lib/src/shared/http/http_request_data.dart
Outdated
Show resolved
Hide resolved
packages/devtools_app/lib/src/shared/http/http_request_data.dart
Outdated
Show resolved
Hide resolved
packages/devtools_app/lib/src/shared/http/http_request_data.dart
Outdated
Show resolved
Hide resolved
packages/devtools_app/lib/src/shared/http/http_request_data.dart
Outdated
Show resolved
Hide resolved
| Widget _buildHttpTimeGraph() { | ||
| final data = this.data as DartIOHttpRequestData; | ||
| if (data.duration == null || data.instantEvents.isEmpty) { | ||
| if (data.duration == null || |
There was a problem hiding this comment.
[nit] pull data.duration into a named variable (e.g. requestDuration) so that we don't need the ! null assertion operator (data.duration!)
| if (value == null) return false; | ||
| final normalized = value.toLowerCase(); | ||
|
|
||
| /// Markers used for substring matching against request / response errors |
There was a problem hiding this comment.
[nit] since this is no longer a static const field, use normal comment markers \\ instead of the dartdoc \\\
| } | ||
|
|
||
| if (isCancelled) { | ||
| return Duration.zero; |
There was a problem hiding this comment.
I think it could still be beneficial to include timing information even if the request was canceled - this matches what Chrome DevTools network panel does.
| if (inProgress || !isValid) return null; | ||
| // Timestamps are in microseconds | ||
| return _endTime!.difference(_request.startTime); | ||
| if (_hasError) { |
There was a problem hiding this comment.
What are these changes for? It looks like the _endTime getter already takes into account whether or not there is an error so I don't think we need to change any of this logic.
| return false; | ||
| } | ||
|
|
||
| final statusCode = _request.response?.statusCode; |
There was a problem hiding this comment.
I understand why adding the isCancelled check is necessary, but why did the rest of the logic here change?
| final statusCode = _request.response?.statusCode; | ||
| if (statusCode != null) return statusCode.toString(); | ||
|
|
||
| if (_hasError) return 'Error'; |
There was a problem hiding this comment.
I think we want to keep the _hasError check before the status code check to preserve the previous order, e.g.
if (isCancelled) return 'Cancelled`;
if (_hasError) return 'Error';
return _request.response?.statusCode.toString();
| @override | ||
| String get uri => _request.uri.toString(); | ||
|
|
||
| bool get isCancelled { |
There was a problem hiding this comment.
It looks like the only reason this is public is to expose it for tests. Can we instead make this private and in the tests check the status to determine whether or not a request is canceled?
There was a problem hiding this comment.
Yep, agreed. I made it private and updated tests to assert via status instead of exposing isCancelled just for testing
| if (_request.request?.error != null && _request.response == null) { | ||
| return true; | ||
| } | ||
|
|
||
| if (_request.endTime != null && _request.response == null) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
What are these checks for? It looks like these are checks for whether the request is in progress but I might be misunderstanding something here
There was a problem hiding this comment.
Those checks were trying to catch cancelled requests when we don’t get a normal response, but I agree they also blurred into in-progress/completed logic. I removed them and kept cancellation detection focused on explicit cancellation signals, with completion handled separately
| 'persistentConnection': true, | ||
| 'uri': 'https://jsonplaceholder.typicode.com/albums/1', | ||
| }, | ||
| 'response': null, // ← key: no response |
…l-safety; update related network tests
elliette
left a comment
There was a problem hiding this comment.
A few more suggestions but this is looking good, thank you!
| if (data.duration == null || | ||
| data.duration!.inMicroseconds == 0 || | ||
| final requestDuration = data.duration; |
There was a problem hiding this comment.
My apologies for the back and forth here - looking at how this is used in _buildTimingRow, I have a better understanding of what this represents now.
It looks like this is the total duration of the request. I think we could make it a private getter _totalDuration that is used here and in _buildTimingRow.
Optionally also change the parameter names for _buildTimingRow:
label->segmentLabelduration->segmentDuration
That should make the code more understandable.
| if (inProgress || !isValid) return null; | ||
| // Timestamps are in microseconds | ||
| return _endTime!.difference(_request.startTime); | ||
| if (inProgress || !isValid) { |
There was a problem hiding this comment.
This method is now the same as it was before, revert to original code. Thanks!
|
|
||
| /// True if the HTTP request hasn't completed yet, determined by the lack of | ||
| /// an end time in the response data. | ||
| @override |
| _hasError ? !_request.isRequestComplete : !_request.isResponseComplete; | ||
| bool get inProgress { | ||
| if (_isCancelled) return false; | ||
| return _request.endTime == null; |
There was a problem hiding this comment.
Should we still be checking for an error here along with using isRequestComplete/isResponseComplete?
There was a problem hiding this comment.
I think inProgress should still consider error/request/response completion
Agreed. Updated inProgress to use request/response completion semantics (including the error path), with cancellation handled separately.
| final statusCode = _request.response?.statusCode; | ||
| if (statusCode != null) return statusCode.toString(); | ||
|
|
||
| return null; |
There was a problem hiding this comment.
This is equivalent to return _request.response?.statusCode.toString() (the original code) because the entire expression will evaluate to null if response is null
| expect(find.text('GET'), findsOneWidget); | ||
| expect(find.text('Status: '), findsOneWidget); | ||
| expect(find.text('Error'), findsOneWidget); | ||
| expect(find.text('Cancelled'), findsOneWidget); |
There was a problem hiding this comment.
Is the change from Error to Cancelled intentional/expected?
There was a problem hiding this comment.
Yes, intentional. httpGetWithError uses a HandshakeException error string which doesn't contain any cancellation markers (cancelled, canceled, aborted), so it correctly resolves to 'Error'. The profiler test already expects 'Error' for that fixture.
| @@ -0,0 +1,34 @@ | |||
| // Copyright 2020 The Flutter Authors | |||
Description
This PR fixes an issue where cancelled HTTP requests appear as "Pending" in the DevTools Network tab.
When a request is aborted (for example using
HttpClientRequest.abortor Dio cancellation), DevTools keeps the request in a Pending state because no response is received. This change detects such cases and displays the request status as "Cancelled" instead.Changes
HttpRequestDatainProgressnullfor cancelled requestsCustomPointerScrollViewto usecacheExtentso the project compiles with the current Flutter SDKAll existing network tests pass locally.
Fixes: #9593
Pre-launch Checklist
If you need help, consider asking for help on Discord.