From d39f241ab91de721137a51f975f752ae86a29277 Mon Sep 17 00:00:00 2001 From: Muhammad Kamel Date: Mon, 17 Aug 2026 11:27:37 +0300 Subject: [PATCH 1/2] Fix Copy as cURL omitting headers and body on failed requests. Timed-out and cancelled POSTs never complete a response, so the Network profiler dropped request data from the generated curl command. --- .../src/screens/network/network_screen.dart | 15 ++-- .../src/shared/http/http_request_data.dart | 21 ++++-- .../release_notes/NEXT_RELEASE_NOTES.md | 2 +- .../test/http/curl_command_test.dart | 69 +++++++++++++++++++ 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index 66843cf09b0..611fbee24d3 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -492,17 +492,20 @@ class ActionsColumn extends ColumnData MenuItemButton( child: const Text('Copy as cURL'), onPressed: () { - unawaited( - copyToClipboard( - CurlCommand.from(data).toString(), - successMessage: 'Copied the cURL command to the clipboard', - ), - ); + unawaited(_copyAsCurl(data)); }, ), ], ); } + + Future _copyAsCurl(DartIOHttpRequestData data) async { + await data.getFullRequestData(); + await copyToClipboard( + CurlCommand.from(data).toString(), + successMessage: 'Copied the cURL command to the clipboard', + ); + } } class StatusColumn extends ColumnData diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart index 81ec0d35528..294b951a23a 100644 --- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart @@ -42,7 +42,10 @@ class DartIOHttpRequestData extends NetworkRequest { this._request, { bool requestFullDataFromVmService = true, }) { - if (requestFullDataFromVmService && _request.isResponseComplete) { + if (requestFullDataFromVmService && + (_request.isResponseComplete || + _request.isRequestComplete || + (_request.request?.hasError ?? false))) { unawaited(getFullRequestData()); } } @@ -308,8 +311,17 @@ class DartIOHttpRequestData extends NetworkRequest { DartIOHttpRequestData._parseCookies(_request.response?.cookies); /// The request headers for the HTTP request. - Map? get requestHeaders => - _hasError ? null : _request.request?.headers; + /// + /// Returned even when the request failed, so failed / timed-out requests can + /// still be replayed (e.g. Copy as cURL). Accessing headers on some error + /// profiles throws, so failures fall back to `null`. + Map? get requestHeaders { + try { + return _request.request?.headers; + } catch (_) { + return null; + } + } /// The response headers for the HTTP request. Map? get responseHeaders => _request.response?.headers; @@ -400,7 +412,8 @@ class DartIOHttpRequestData extends NetworkRequest { } final fullRequest = _request as HttpProfileRequest; try { - if (!_request.isResponseComplete) return null; + // Request body is independent of whether a response arrived. Timed-out + // and cancelled POSTs should still expose the body for Copy as cURL. final acceptedMethods = {'POST', 'PUT', 'PATCH'}; if (!acceptedMethods.contains(_request.method)) return null; if (_requestBody != null) return _requestBody; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 1f6fafdf6fd..1184d17758b 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -42,7 +42,7 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates -TODO: Remove this section if there are not any updates. +- Fixed Copy as cURL omitting request headers and body for failed or timed-out requests. [TODO](https://github.com/flutter/devtools/pull/TODO) ## Logging updates diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart index 81cda7cb8fc..e23825c83a3 100644 --- a/packages/devtools_app/test/http/curl_command_test.dart +++ b/packages/devtools_app/test/http/curl_command_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; import 'dart:typed_data'; import 'package:devtools_app/devtools_app.dart'; @@ -205,6 +206,74 @@ void main() { "curl --location --request POST 'https://jsonplaceholder.typicode.com/posts' \\\n--data-raw '{\n \"title\": \"foo\", \"body\": \"bar\", \"userId\": 1\n}\n '", ); }); + + test('includes headers and body when response never completes', () { + final data = DartIOHttpRequestData( + HttpProfileRequest.parse({ + 'id': '7', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + 'endTime': 1000, + 'request': { + 'headers': { + 'content-type': ['application/json'], + 'accept': ['application/json'], + 'locale': ['en'], + }, + 'contentLength': 42, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': false, + }, + 'response': null, + 'requestBody': utf8.encode( + '{"email":"user@example.com","password":"secret"}', + ), + })!, + requestFullDataFromVmService: false, + ); + + expect( + data.requestBody, + '{"email":"user@example.com","password":"secret"}', + ); + expect( + CurlCommand.from(data).toString(), + "curl --location --request POST 'https://example.com/api/login' \\\n--header 'content-type: application/json' \\\n--header 'accept: application/json' \\\n--header 'locale: en' \\\n--data-raw '{\"email\":\"user@example.com\",\"password\":\"secret\"}'", + ); + }); + + test('includes body when request has an error', () { + final data = DartIOHttpRequestData( + HttpProfileRequest.parse({ + 'id': '8', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + 'endTime': 1000, + 'request': { + 'error': 'Connection timed out', + 'contentLength': 2, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': false, + }, + 'response': null, + 'requestBody': utf8.encode('{}'), + })!, + requestFullDataFromVmService: false, + ); + + expect(data.requestBody, '{}'); + expect(CurlCommand.from(data).toString(), contains("--data-raw '{}'")); + }); }); } From 432d39c6e69d39e17d32c07ed06f0faa72b7cef3 Mon Sep 17 00:00:00 2001 From: Muhammad Kamel Date: Mon, 17 Aug 2026 11:28:40 +0300 Subject: [PATCH 2/2] Update release notes with PR number. --- packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 1184d17758b..ff6f70a6828 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -42,7 +42,7 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates -- Fixed Copy as cURL omitting request headers and body for failed or timed-out requests. [TODO](https://github.com/flutter/devtools/pull/TODO) +- Fixed Copy as cURL omitting request headers and body for failed or timed-out requests. [#9963](https://github.com/flutter/devtools/pull/9963) ## Logging updates