Skip to content

Commit 73715af

Browse files
committed
Fix cancelled HTTP requests showing as Pending in DevTools Network tab (flutter#9685)
## 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: flutter#9593 ![build.yaml badge] If you need help, consider asking for help on [Discord]. [build.yaml badge]: https://github.com/flutter/devtools/actions/workflows/build.yaml/badge.svg
1 parent 92f9d74 commit 73715af

9 files changed

Lines changed: 405 additions & 44 deletions

File tree

packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -707,21 +707,31 @@ class NetworkRequestOverviewView extends StatelessWidget {
707707
];
708708
}
709709

710-
Widget _buildTimingRow(Color color, String label, Duration duration) {
711-
final flex = (duration.inMicroseconds / data.duration!.inMicroseconds * 100)
712-
.round();
710+
Duration? get _totalDuration => (data as DartIOHttpRequestData).duration;
711+
712+
Widget _buildTimingRow(
713+
Color color,
714+
String segmentLabel,
715+
Duration segmentDuration,
716+
) {
717+
final totalDuration = _totalDuration!;
718+
final flex =
719+
(segmentDuration.inMicroseconds / totalDuration.inMicroseconds * 100)
720+
.round();
713721
return Flexible(
714722
flex: flex,
715723
child: DevToolsTooltip(
716-
message: '$label - ${durationText(duration)}',
724+
message: '$segmentLabel - ${durationText(segmentDuration)}',
717725
child: Container(height: _timingGraphHeight, color: color),
718726
),
719727
);
720728
}
721729

722730
Widget _buildHttpTimeGraph() {
723731
final data = this.data as DartIOHttpRequestData;
724-
if (data.duration == null || data.instantEvents.isEmpty) {
732+
if (_totalDuration == null ||
733+
_totalDuration!.inMicroseconds == 0 ||
734+
data.instantEvents.isEmpty) {
725735
return Container(
726736
key: httpTimingGraphKey,
727737
height: 18.0,

packages/devtools_app/lib/src/shared/http/http_request_data.dart

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,38 @@ class DartIOHttpRequestData extends NetworkRequest {
135135

136136
bool get _hasError => _request.request?.hasError ?? false;
137137

138-
DateTime? get _endTime =>
139-
_hasError ? _request.endTime : _request.response?.endTime;
138+
DateTime? get _endTime => (_hasError || _isCancelled)
139+
? _request.endTime
140+
: _request.response?.endTime;
141+
142+
bool _matchesCancellationMarker(String? value) {
143+
if (value == null) return false;
144+
final normalized = value.toLowerCase();
145+
146+
// Markers used for substring matching against request / response errors
147+
// and request event names to classify cancelled requests.
148+
//
149+
// Derived from observed cancellation wording in HTTP profiler payloads,
150+
// keeping specific terms to reduce false positives.
151+
const cancellationMarkers = ['canceled', 'cancelled', 'aborted'];
152+
153+
return cancellationMarkers.any(normalized.contains);
154+
}
155+
156+
bool get _hasCancellationError {
157+
final requestError = _request.request?.error;
158+
final responseError = _request.response?.error;
159+
return _matchesCancellationMarker(requestError) ||
160+
_matchesCancellationMarker(responseError);
161+
}
162+
163+
bool get _hasCancellationEvent =>
164+
_request.events.any((event) => _matchesCancellationMarker(event.event));
140165

141166
@override
142167
Duration? get duration {
143168
if (inProgress || !isValid) return null;
144-
// Timestamps are in microseconds
145-
return _endTime!.difference(_request.startTime);
169+
return _endTime?.difference(_request.startTime);
146170
}
147171

148172
/// Whether the request is safe to display in the UI.
@@ -156,7 +180,7 @@ class DartIOHttpRequestData extends NetworkRequest {
156180
return {
157181
'method': _request.method,
158182
'uri': _request.uri.toString(),
159-
if (!didFail) ...{
183+
if (!didFail && !_isCancelled) ...{
160184
'connectionInfo': _request.request?.connectionInfo,
161185
'contentLength': _request.request?.contentLength,
162186
},
@@ -227,11 +251,15 @@ class DartIOHttpRequestData extends NetworkRequest {
227251
return connectionInfo != null ? connectionInfo[_localPortKey] : null;
228252
}
229253

230-
/// True if the HTTP request hasn't completed yet, determined by the lack of
231-
/// an end time in the response data.
254+
/// True if the HTTP request hasn't completed yet, determined by
255+
/// `isRequestComplete` / `isResponseComplete` from the profile data.
232256
@override
233-
bool get inProgress =>
234-
_hasError ? !_request.isRequestComplete : !_request.isResponseComplete;
257+
bool get inProgress {
258+
if (_isCancelled) return false;
259+
return _hasError
260+
? !_request.isRequestComplete
261+
: !_request.isResponseComplete;
262+
}
235263

236264
/// All instant events logged to the timeline for this HTTP request.
237265
List<DartIOHttpInstantEvent> get instantEvents {
@@ -273,6 +301,7 @@ class DartIOHttpRequestData extends NetworkRequest {
273301
bool get didFail {
274302
if (status == null) return false;
275303
if (status == 'Error') return true;
304+
if (status == 'Cancelled') return false;
276305

277306
try {
278307
final code = int.parse(status!);
@@ -301,12 +330,19 @@ class DartIOHttpRequestData extends NetworkRequest {
301330
DateTime get startTimestamp => _request.startTime;
302331

303332
@override
304-
String? get status =>
305-
_hasError ? 'Error' : _request.response?.statusCode.toString();
333+
String? get status {
334+
if (_isCancelled) return 'Cancelled';
335+
336+
if (_hasError) return 'Error';
337+
338+
return _request.response?.statusCode.toString();
339+
}
306340

307341
@override
308342
String get uri => _request.uri.toString();
309343

344+
bool get _isCancelled => _hasCancellationError || _hasCancellationEvent;
345+
310346
String? get responseBody {
311347
if (_request is! HttpProfileRequest) {
312348
return null;

packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ TODO: Remove this section if there are not any updates.
4141

4242
## Network profiler updates
4343

44-
TODO: Remove this section if there are not any updates.
44+
- Improved HTTP request status classification in the Network tab to better distinguish cancelled, completed, and in-flight requests (for example, avoiding some cases where cancelled requests appeared as pending). [#9683](https://github.com/flutter/devtools/pull/9683)
4545

4646
## Logging updates
4747

packages/devtools_app/test/screens/network/network_controller_test.dart

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,17 @@ void main() {
109109
expect(requests.length, numRequests);
110110
final httpRequests = requests.whereType<DartIOHttpRequestData>().toList();
111111
for (final request in httpRequests) {
112-
expect(request.duration, request.inProgress ? isNull : isNotNull);
112+
expect(
113+
request.duration,
114+
request.inProgress || request.endTimestamp == null
115+
? isNull
116+
: isNotNull,
117+
);
113118
expect(request.general.length, greaterThan(0));
114119
expect(httpMethods.contains(request.method), true);
115-
expect(request.status, request.inProgress ? isNull : isNotNull);
120+
if (request.inProgress) {
121+
expect(request.status, isNull);
122+
}
116123
}
117124

118125
// Finally, call `clear()` and ensure the requests have been cleared.
@@ -205,15 +212,31 @@ void main() {
205212

206213
controller.setActiveFilter(query: 'status:Error');
207214
expect(profile, hasLength(numRequests));
208-
expect(controller.filteredData.value, hasLength(1));
209-
210-
controller.setActiveFilter(query: 's:101');
215+
final errorCount = profile
216+
.whereType<DartIOHttpRequestData>()
217+
.where((request) => request.status == 'Error')
218+
.length;
219+
expect(controller.filteredData.value, hasLength(errorCount));
220+
221+
final firstStatus = profile
222+
.whereType<DartIOHttpRequestData>()
223+
.map((request) => request.status)
224+
.whereType<String>()
225+
.first;
226+
final firstStatusCount = profile
227+
.whereType<DartIOHttpRequestData>()
228+
.where((request) => request.status == firstStatus)
229+
.length;
230+
controller.setActiveFilter(query: 's:$firstStatus');
211231
expect(profile, hasLength(numRequests));
212-
expect(controller.filteredData.value, hasLength(1));
232+
expect(controller.filteredData.value, hasLength(firstStatusCount));
213233

214234
controller.setActiveFilter(query: '-s:Error');
215235
expect(profile, hasLength(numRequests));
216-
expect(controller.filteredData.value, hasLength(8));
236+
expect(
237+
controller.filteredData.value,
238+
hasLength(numRequests - errorCount),
239+
);
217240

218241
controller.setActiveFilter(query: 'type:json');
219242
expect(profile, hasLength(numRequests));
@@ -253,11 +276,28 @@ void main() {
253276

254277
controller.setActiveFilter(query: '-status:error method:get');
255278
expect(profile, hasLength(numRequests));
256-
expect(controller.filteredData.value, hasLength(3));
279+
final nonErrorGetCount = profile
280+
.whereType<DartIOHttpRequestData>()
281+
.where(
282+
(request) =>
283+
request.method.toLowerCase() == 'get' &&
284+
request.status?.toLowerCase() != 'error',
285+
)
286+
.length;
287+
expect(controller.filteredData.value, hasLength(nonErrorGetCount));
257288

258289
controller.setActiveFilter(query: '-status:error method:get t:http');
259290
expect(profile, hasLength(numRequests));
260-
expect(controller.filteredData.value, hasLength(2));
291+
final nonErrorGetHttpCount = profile
292+
.whereType<DartIOHttpRequestData>()
293+
.where(
294+
(request) =>
295+
request.method.toLowerCase() == 'get' &&
296+
request.status?.toLowerCase() != 'error' &&
297+
request.type.toLowerCase() == 'http',
298+
)
299+
.length;
300+
expect(controller.filteredData.value, hasLength(nonErrorGetHttpCount));
261301
});
262302

263303
test('filterData hides tcp sockets via setting filter', () async {
@@ -341,6 +381,21 @@ void main() {
341381
'statusCode': 200,
342382
},
343383
})!;
384+
final request1CancelledWithStatusCode = HttpProfileRequest.parse({
385+
...httpBaseObject,
386+
'events': [
387+
{
388+
'timestamp': startTime + 100,
389+
'event': 'Request cancelled by client',
390+
},
391+
],
392+
'response': {
393+
'startTime': startTime,
394+
'endTime': null,
395+
'redirects': [],
396+
'statusCode': 200,
397+
},
398+
})!;
344399
final request2Pending = HttpProfileRequest.parse({
345400
...httpBaseObject,
346401
'id': '102',
@@ -403,6 +458,30 @@ void main() {
403458
},
404459
);
405460

461+
test('latest request update wins over stale status for same id', () {
462+
currentNetworkRequests.updateOrAddAll(
463+
requests: [request1Done],
464+
sockets: const [],
465+
timelineMicrosOffset: 0,
466+
);
467+
468+
final initialRequest =
469+
currentNetworkRequests.getRequest('101')! as DartIOHttpRequestData;
470+
expect(initialRequest.status, '200');
471+
expect(initialRequest.status, isNot('Cancelled'));
472+
473+
currentNetworkRequests.updateOrAddAll(
474+
requests: [request1CancelledWithStatusCode],
475+
sockets: const [],
476+
timelineMicrosOffset: 0,
477+
);
478+
479+
final updatedRequest =
480+
currentNetworkRequests.getRequest('101')! as DartIOHttpRequestData;
481+
expect(updatedRequest.status, 'Cancelled');
482+
expect(updatedRequest.inProgress, false);
483+
});
484+
406485
test('clear', () {
407486
final reqs = [request1Pending, request2Pending];
408487
final sockets = [socketStats1Pending, socketStats2Pending];

0 commit comments

Comments
 (0)