Skip to content

Commit 1387d08

Browse files
committed
Stabilize web integration tests for DWDS Promise collected flake
1 parent 4a5b354 commit 1387d08

4 files changed

Lines changed: 81 additions & 12 deletions

File tree

packages/devtools_app_shared/lib/src/service/rpc_error_extension.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
import 'package:vm_service/vm_service.dart';
66

77
extension RpcErrorExtension on RPCError {
8+
/// Whether this [RPCError] represents a transient DWDS/CDP failure where a
9+
/// JS promise was garbage collected before completion.
10+
bool get isDwdsPromiseCollectedError {
11+
return message.contains('Promise was collected') ||
12+
toString().contains('Promise was collected');
13+
}
14+
815
/// Whether this [RPCError] is some kind of "VM Service connection has gone"
916
/// error that may occur if the VM is shut down.
1017
bool get isServiceDisposedError {

packages/devtools_app_shared/lib/src/service/service_extension_manager.dart

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import 'service_extensions.dart' as extensions;
1919
import 'service_utils.dart';
2020

2121
final _log = Logger('service_extension_manager');
22+
const _dwdsTransientRetryDelay = Duration(milliseconds: 100);
2223

2324
/// Manager that handles tracking the service extension for the main isolate.
2425
final class ServiceExtensionManager with DisposerMixin {
@@ -59,6 +60,40 @@ final class ServiceExtensionManager with DisposerMixin {
5960
ConnectedApp get connectedApp => _connectedApp!;
6061
ConnectedApp? _connectedApp;
6162

63+
bool _shouldRetryServiceExtensionCall(Object error) {
64+
return connectedApp.isDebuggableWebApp &&
65+
error is RPCError &&
66+
error.isDwdsPromiseCollectedError;
67+
}
68+
69+
Future<Response> _callServiceExtensionWithRetry(
70+
String method, {
71+
String? isolateId,
72+
Map<String, Object?>? args,
73+
}) async {
74+
try {
75+
return await _service!.callServiceExtension(
76+
method,
77+
isolateId: isolateId,
78+
args: args,
79+
);
80+
} catch (error, st) {
81+
if (!_shouldRetryServiceExtensionCall(error)) rethrow;
82+
83+
_log.info(
84+
'Retrying transient DWDS error for service extension $method: $error',
85+
error,
86+
st,
87+
);
88+
await Future<void>.delayed(_dwdsTransientRetryDelay);
89+
return await _service!.callServiceExtension(
90+
method,
91+
isolateId: isolateId,
92+
args: args,
93+
);
94+
}
95+
}
96+
6297
Future<void> _handleIsolateEvent(Event event) async {
6398
if (event.kind == EventKind.kServiceExtensionAdded) {
6499
// On hot restart, service extensions are added from here.
@@ -207,7 +242,7 @@ final class ServiceExtensionManager with DisposerMixin {
207242
_checkForFirstFrameStarted = true;
208243

209244
try {
210-
final value = await _service!.callServiceExtension(
245+
final value = await _callServiceExtensionWithRetry(
211246
extensions.didSendFirstFrameEvent,
212247
isolateId: lastMainIsolate.id,
213248
);
@@ -317,7 +352,7 @@ final class ServiceExtensionManager with DisposerMixin {
317352
// The restore request is obsolete if the isolate has changed.
318353
if (isolateRef != _mainIsolate) return false;
319354
try {
320-
final response = await _service!.callServiceExtension(
355+
final response = await _callServiceExtensionWithRetry(
321356
name,
322357
isolateId: isolateRef.id,
323358
);
@@ -403,7 +438,7 @@ final class ServiceExtensionManager with DisposerMixin {
403438
try {
404439
if (value is bool) {
405440
Future<void> call(String? isolateId, bool value) async {
406-
await _service!.callServiceExtension(
441+
await _callServiceExtensionWithRetry(
407442
name,
408443
isolateId: isolateId,
409444
args: {'enabled': value},
@@ -423,13 +458,13 @@ final class ServiceExtensionManager with DisposerMixin {
423458
await call(mainIsolate.id, value);
424459
}
425460
} else if (value is String) {
426-
await _service!.callServiceExtension(
461+
await _callServiceExtensionWithRetry(
427462
name,
428463
isolateId: mainIsolate.id,
429464
args: {'value': value},
430465
);
431466
} else if (value is double) {
432-
await _service!.callServiceExtension(
467+
await _callServiceExtensionWithRetry(
433468
name,
434469
isolateId: mainIsolate.id,
435470
// The param name for a numeric service extension will be the last part
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
import 'package:devtools_app_shared/src/service/rpc_error_extension.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:vm_service/vm_service.dart';
8+
9+
void main() {
10+
group('RpcErrorExtension', () {
11+
test('detects DWDS promise collected errors', () {
12+
final error = RPCError(
13+
'Unexpected DWDS error: WipError -32000 Promise was collected',
14+
RPCErrorKind.kServerError.code,
15+
);
16+
17+
expect(error.isDwdsPromiseCollectedError, isTrue);
18+
});
19+
20+
test('does not classify non-DWDS server errors as promise collected', () {
21+
final error = RPCError(
22+
'The client is closed',
23+
RPCErrorKind.kServerError.code,
24+
);
25+
26+
expect(error.isDwdsPromiseCollectedError, isFalse);
27+
});
28+
});
29+
}

packages/devtools_shared/lib/src/test/integration_test_runner.dart

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class IntegrationTestRunner with IOMixin {
4949
'--driver=$testDriver',
5050
'--target=$testTarget',
5151
'-d',
52-
headless ? 'web-server' : 'chrome',
52+
'chrome',
5353
// --disable-gpu speeds up tests that use ChromeDriver when run on
5454
// GitHub Actions. See https://github.com/flutter/devtools/issues/8301.
5555
// However, it also breaks the tests when running with the wasm flag,
@@ -223,8 +223,7 @@ class IntegrationTestRunnerArgs {
223223
/// The path to the test target.
224224
String? get testTarget => argResults.option(testTargetArg);
225225

226-
/// Whether this integration test should be run on the 'web-server' device
227-
/// instead of 'chrome'.
226+
/// Whether this integration test should be run in headless Chrome.
228227
bool get headless => argResults.flag(_headlessArg);
229228

230229
/// Whether this integration test should be run against dart2wasm-compiled DevTools.
@@ -280,10 +279,9 @@ class IntegrationTestRunnerArgs {
280279
..addFlag(
281280
_headlessArg,
282281
negatable: false,
283-
help:
284-
'Runs the integration test on the \'web-server\' device instead of '
285-
'the \'chrome\' device. For headless test runs, you will not be '
286-
'able to see the integration test run visually in a Chrome browser.',
282+
help: 'Runs the integration test on the \'chrome\' device in headless '
283+
'mode. For headless test runs, you will not be able to see the '
284+
'integration test run visually in a Chrome browser.',
287285
)
288286
..addFlag(
289287
_wasmArg,

0 commit comments

Comments
 (0)