Skip to content

Fix cancelled HTTP requests showing as Pending in DevTools Network tab#9685

Open
rishika0212 wants to merge 14 commits intoflutter:masterfrom
rishika0212:fix-network-cancelled-status
Open

Fix cancelled HTTP requests showing as Pending in DevTools Network tab#9685
rishika0212 wants to merge 14 commits intoflutter:masterfrom
rishika0212:fix-network-cancelled-status

Conversation

@rishika0212
Copy link
Copy Markdown
Contributor

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.abort or 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

  • Detect cancelled/aborted requests in HttpRequestData
  • Display "Cancelled" instead of "Pending" in the Network table
  • Ensure cancelled requests are no longer treated as inProgress
  • Prevent duration from remaining null for cancelled requests
  • Add a regression test to verify the behavior
  • Update CustomPointerScrollView to use cacheExtent so the project compiles with the current Flutter SDK

All existing network tests pass locally.

Fixes: #9593

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read the Flutter Style Guide recently and followed its advice.
  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I added tests to check the change I am making.

build.yaml badge

If you need help, consider asking for help on Discord.

@rishika0212 rishika0212 requested review from a team, bkonyi and kenzieschmoll as code owners March 5, 2026 16:38
Copy link
Copy Markdown
Contributor

@srawlins srawlins left a comment

Choose a reason for hiding this comment

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

This code looks great; thanks for the PR! I'd like to test manually before landing.

super.shrinkWrap,
super.padding,
super.scrollCacheExtent,
super.cacheExtent,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CC @elliette ; from the PR description:

Update CustomPointerScrollView to use cacheExtent so the project compiles with the current Flutter SDK

Copy link
Copy Markdown
Contributor

@bkonyi bkonyi left a comment

Choose a reason for hiding this comment

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

LGTM overall!

@srawlins
Copy link
Copy Markdown
Contributor

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:

  1. Send a GET request.
  2. The server starts to send a response, (the "Response Body" checkbox is checked).
  3. After 1 second, the request is cancelled by the client.
  4. The server never closes the response.

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?

@srawlins
Copy link
Copy Markdown
Contributor

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.

@rishika0212
Copy link
Copy Markdown
Contributor Author

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:

  • Completes → behaves as expected (200)
  • Does not complete → stays pending (-), as expected
  • Cancelled before completion → now correctly shows as cancelled

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):
I agree that ideally this should show as Cancelled.

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…

  • The original issue (Cancelled showing as Pending) should now be fixed
  • The “response-started then cancelled” case seems more like a limitation in the profiling signal (companion/VM service), rather than just UI logic

Let me know if you’re seeing anything different on your side!

@srawlins
Copy link
Copy Markdown
Contributor

I also tightened up the DevTools logic a bit to avoid showing “Cancelled” too early before a response actually completes.

Do you mean you've done this before today, or you will upload a commit?

@rishika0212
Copy link
Copy Markdown
Contributor Author

I also tightened up the DevTools logic a bit to avoid showing “Cancelled” too early before a response actually completes.

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.

@rishika0212
Copy link
Copy Markdown
Contributor Author

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:

  1. a real completion
  2. a request that got cancelled midway

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:

  1. if we’re too aggressive → we show false Cancelled
  2. if we’re too conservative → we miss actual cancellations

@srawlins
Copy link
Copy Markdown
Contributor

I can push the tested improvements now (mainly the cancelled vs pending behavior).

That would be great!

The “response already started, then cancelled” case is still not fully reliable yet since the profiling signal is ambiguous in some runs.

This sounds OK to me.

@rishika0212
Copy link
Copy Markdown
Contributor Author

I can push the tested improvements now (mainly the cancelled vs pending behavior).

That would be great!

I've pushed the updates.

@rishika0212 rishika0212 requested a review from srawlins March 21, 2026 18:03
Copy link
Copy Markdown
Contributor

@srawlins srawlins left a comment

Choose a reason for hiding this comment

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

Just a couple of small changes; thanks!

'cancel',
'canceled',
'cancelled',
'operation canceled',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

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.

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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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."

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.

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.

Widget _buildHttpTimeGraph() {
final data = this.data as DartIOHttpRequestData;
if (data.duration == null || data.instantEvents.isEmpty) {
if (data.duration == null ||
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] since this is no longer a static const field, use normal comment markers \\ instead of the dartdoc \\\

}

if (isCancelled) {
return Duration.zero;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

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.

Yep, agreed. I made it private and updated tests to assert via status instead of exposing isCancelled just for testing

Comment on lines +375 to +381
if (_request.request?.error != null && _request.response == null) {
return true;
}

if (_request.endTime != null && _request.response == null) {
return true;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove key comment

@rishika0212 rishika0212 requested a review from elliette March 31, 2026 16:22
Copy link
Copy Markdown
Member

@elliette elliette left a comment

Choose a reason for hiding this comment

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

A few more suggestions but this is looking good, thank you!

Comment on lines +724 to +729
if (data.duration == null ||
data.duration!.inMicroseconds == 0 ||
final requestDuration = data.duration;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 -> segmentLabel
  • duration -> 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please add dartdoc back

_hasError ? !_request.isRequestComplete : !_request.isResponseComplete;
bool get inProgress {
if (_isCancelled) return false;
return _request.endTime == null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we still be checking for an error here along with using isRequestComplete/isResponseComplete?

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 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.

Comment on lines +336 to +339
final statusCode = _request.response?.statusCode;
if (statusCode != null) return statusCode.toString();

return null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the change from Error to Cancelled intentional/expected?

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.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

2026

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.

Cancelled requests are shown as "Pending" in Devtools Network Tab

5 participants