Skip to content

Commit 4de6a49

Browse files
yjbanovGitHub Actions Bot
authored and
GitHub Actions Bot
committed
[web] robustify safaridriver launch sequence (flutter#162919)
Improve safaridriver launch sequence as follows: - Fix retry: the previous version would call `_startDriverProcess` recursively from within a listener to the stderr output. But by then the outer `_startDriverProcess` call would have completed its future, so the retry mechanism would kick in while tests are already running. - Wait for `safaridriver` server process to start listening and responding to WebDriver commands (using `/status` as the smoke test). - Smoke-test the driver session by attempting to list all windows (`WebDriver.windows`). - When `safaridriver` fails to pass all of the above checks, both close the session and kill the `safaridriver` process. Killing the process alone leaves Safari windows open. Closing the session alone leaves `safaridriver` processes open. - When tests are finished use `quit()` instead of `window.close()`, because the latter does not close the session.
1 parent 10998cd commit 4de6a49

File tree

3 files changed

+168
-105
lines changed

3 files changed

+168
-105
lines changed

engine/src/flutter/lib/web_ui/dev/pipeline.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,11 @@ abstract class ProcessStep implements PipelineStep {
8989
}
9090

9191
class _PipelineStepFailure {
92-
_PipelineStepFailure(this.step, this.error);
92+
_PipelineStepFailure(this.step, this.error, this.stackTrace);
9393

9494
final PipelineStep step;
9595
final Object error;
96+
final StackTrace stackTrace;
9697
}
9798

9899
/// Executes a sequence of asynchronous tasks, typically as part of a build/test
@@ -133,8 +134,8 @@ class Pipeline {
133134
_currentStepFuture = step.run();
134135
try {
135136
await _currentStepFuture;
136-
} catch (e) {
137-
failures.add(_PipelineStepFailure(step, e));
137+
} catch (error, stackTrace) {
138+
failures.add(_PipelineStepFailure(step, error, stackTrace));
138139
} finally {
139140
_currentStep = null;
140141
}
@@ -145,7 +146,7 @@ class Pipeline {
145146
_status = PipelineStatus.error;
146147
print('Pipeline experienced the following failures:');
147148
for (final _PipelineStepFailure failure in failures) {
148-
print(' "${failure.step.description}": ${failure.error}');
149+
print(' "${failure.step.description}": ${failure.error}\n${failure.stackTrace}');
149150
}
150151
throw ToolExit('Test pipeline failed.');
151152
}

engine/src/flutter/lib/web_ui/dev/safari_macos.dart

Lines changed: 152 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ import 'dart:convert';
77
import 'dart:io';
88

99
import 'package:test_api/backend.dart';
10+
import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
1011

12+
import 'browser.dart';
1113
import 'webdriver_browser.dart';
1214

1315
/// Provides an environment for the desktop variant of Safari running on macOS.
14-
class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
16+
class SafariMacOsEnvironment extends BrowserEnvironment {
17+
static const Duration _waitBetweenRetries = Duration(seconds: 1);
18+
static const int _maxRetryCount = 5;
19+
20+
late int _portNumber;
21+
late Process _driverProcess;
22+
Uri get _driverUri => Uri(scheme: 'http', host: 'localhost', port: _portNumber);
23+
WebDriver? webDriver;
24+
1525
@override
1626
final String name = 'Safari macOS';
1727

@@ -22,20 +32,33 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
2232
String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml';
2333

2434
@override
25-
Uri get driverUri => Uri(scheme: 'http', host: 'localhost', port: portNumber);
26-
27-
late Process _driverProcess;
28-
int _retryCount = 0;
29-
static const int _waitBetweenRetryInSeconds = 1;
30-
static const int _maxRetryCount = 10;
35+
Future<void> prepare() async {
36+
int retryCount = 0;
3137

32-
@override
33-
Future<Process> spawnDriverProcess() =>
34-
Process.start('safaridriver', <String>['-p', portNumber.toString()]);
38+
while (true) {
39+
try {
40+
if (retryCount > 0) {
41+
print('Retry #$retryCount');
42+
}
43+
retryCount += 1;
44+
await _startDriverProcess();
45+
return;
46+
} catch (error, stackTrace) {
47+
if (retryCount < _maxRetryCount) {
48+
print('''
49+
Failed to start safaridriver:
3550
36-
@override
37-
Future<void> prepare() async {
38-
await _startDriverProcess();
51+
Error: $error
52+
$stackTrace
53+
''');
54+
print('Will try again.');
55+
await Future<void>.delayed(_waitBetweenRetries);
56+
} else {
57+
print('Too many retries. Giving up.');
58+
rethrow;
59+
}
60+
}
61+
}
3962
}
4063

4164
/// Pick an unused port and start `safaridriver` using that port.
@@ -45,36 +68,130 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
4568
/// again with a different port. Wait [_waitBetweenRetryInSeconds] seconds
4669
/// between retries. Try up to [_maxRetryCount] times.
4770
Future<void> _startDriverProcess() async {
48-
_retryCount += 1;
49-
if (_retryCount > 1) {
50-
await Future<void>.delayed(const Duration(seconds: _waitBetweenRetryInSeconds));
51-
}
52-
portNumber = await pickUnusedPort();
71+
_portNumber = await pickUnusedPort();
72+
print('Starting safaridriver on port $_portNumber');
73+
74+
try {
75+
_driverProcess = await Process.start('safaridriver', <String>['-p', _portNumber.toString()]);
76+
77+
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
78+
String log,
79+
) {
80+
print('[safaridriver] $log');
81+
});
82+
83+
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
84+
String error,
85+
) {
86+
print('[safaridriver][error] $error');
87+
});
88+
89+
await _waitForSafariDriverServerReady();
5390

54-
print('Attempt $_retryCount to start safaridriver on port $portNumber');
91+
// Smoke-test the web driver process by connecting to it and asking for a
92+
// list of windows. It doesn't matter how many windows there are.
93+
webDriver = await createDriver(
94+
uri: _driverUri,
95+
desired: <String, dynamic>{'browserName': packageTestRuntime.identifier},
96+
);
5597

56-
_driverProcess = await spawnDriverProcess();
98+
await webDriver!.windows.toList();
99+
} catch (_) {
100+
print('safaridriver failed to start.');
57101

58-
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
59-
String error,
60-
) {
61-
print('[Webdriver][Error] $error');
62-
if (_retryCount > _maxRetryCount) {
63-
print('[Webdriver][Error] Failed to start after $_maxRetryCount tries.');
64-
} else if (error.contains('Operation not permitted')) {
65-
_driverProcess.kill();
66-
_startDriverProcess();
102+
final badDriver = webDriver;
103+
webDriver = null; // let's not keep faulty driver around
104+
105+
if (badDriver != null) {
106+
// This means the launch process got to a point where a WebDriver
107+
// instance was created, but it failed the smoke test. To make sure no
108+
// stray driver sessions are left hanging, try to close the session.
109+
try {
110+
// The method is called "quit" but all it does is close the session.
111+
//
112+
// See: https://www.w3.org/TR/webdriver2/#delete-session
113+
await badDriver.quit();
114+
} catch (error, stackTrace) {
115+
// Just print. Do not rethrow. The attempt to close the session is
116+
// only a best-effort thing.
117+
print('''
118+
Failed to close driver session. Will try to kill the safaridriver process.
119+
120+
Error: $error
121+
$stackTrace
122+
''');
123+
}
67124
}
68-
});
69-
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
70-
String log,
71-
) {
72-
print('[Webdriver] $log');
73-
});
125+
126+
// Try to kill gracefully using SIGTERM first.
127+
_driverProcess.kill();
128+
await _driverProcess.exitCode.timeout(
129+
const Duration(seconds: 2),
130+
onTimeout: () async {
131+
// If the process fails to exit gracefully in a reasonable amount of
132+
// time, kill it forcefully.
133+
print('safaridriver failed to exit normally. Killing with SIGKILL.');
134+
_driverProcess.kill(ProcessSignal.sigkill);
135+
return 0;
136+
},
137+
);
138+
139+
// Rethrow the error to allow the caller to retry, if need be.
140+
rethrow;
141+
}
142+
}
143+
144+
/// The Safari Driver process cannot instantly spawn a server, so this function
145+
/// attempts to connect to the server in a loop until it succeeds.
146+
///
147+
/// A healthy driver process is expected to respond to a `GET /status` HTTP
148+
/// request with `{value: {ready: true}}` JSON response.
149+
///
150+
/// See also: https://www.w3.org/TR/webdriver2/#status
151+
Future<void> _waitForSafariDriverServerReady() async {
152+
// Wait just a tiny bit before connecting for the very first time because
153+
// frequently safaridriver isn't quick enough to bring up the server.
154+
//
155+
// 100ms seems enough in most cases, but feel free to revisit this.
156+
await Future<void>.delayed(const Duration(milliseconds: 100));
157+
158+
int retryCount = 0;
159+
while (true) {
160+
retryCount += 1;
161+
final httpClient = HttpClient();
162+
try {
163+
final request = await httpClient.get('localhost', _portNumber, '/status');
164+
final response = await request.close();
165+
final stringData = await response.transform(utf8.decoder).join();
166+
final jsonResponse = json.decode(stringData) as Map<String, Object?>;
167+
final value = jsonResponse['value']! as Map<String, Object?>;
168+
final ready = value['ready']! as bool;
169+
if (ready) {
170+
break;
171+
}
172+
} catch (_) {
173+
if (retryCount < 10) {
174+
print('safaridriver not ready yet. Waiting...');
175+
await Future<void>.delayed(const Duration(milliseconds: 100));
176+
} else {
177+
print(
178+
'safaridriver failed to reach ready state in a reasonable amount of time. Giving up.',
179+
);
180+
rethrow;
181+
}
182+
}
183+
}
184+
}
185+
186+
@override
187+
Future<Browser> launchBrowserInstance(Uri url, {bool debug = false}) async {
188+
return WebDriverBrowser(webDriver!, url);
74189
}
75190

76191
@override
77192
Future<void> cleanup() async {
193+
// WebDriver.quit() is not called here, because that's done in
194+
// WebDriverBrowser.close().
78195
_driverProcess.kill();
79196
}
80197
}

engine/src/flutter/lib/web_ui/dev/webdriver_browser.dart

Lines changed: 11 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,25 @@
33
// found in the LICENSE file.
44

55
import 'dart:async';
6-
import 'dart:convert';
76
import 'dart:io';
87
import 'dart:math';
98

109
import 'package:image/image.dart';
11-
import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
10+
import 'package:webdriver/async_io.dart' show WebDriver;
1211

1312
import 'browser.dart';
1413

15-
abstract class WebDriverBrowserEnvironment extends BrowserEnvironment {
16-
late int portNumber;
17-
late final Process _driverProcess;
18-
19-
Future<Process> spawnDriverProcess();
20-
Uri get driverUri;
21-
22-
/// Finds and returns an unused port on the test host in the local port range.
23-
Future<int> pickUnusedPort() async {
24-
// Use bind to allocate an unused port, then unbind from that port to
25-
// make it available for use.
26-
final ServerSocket socket = await ServerSocket.bind('localhost', 0);
27-
final int port = socket.port;
28-
await socket.close();
29-
30-
return port;
31-
}
32-
33-
@override
34-
Future<void> prepare() async {
35-
portNumber = await pickUnusedPort();
36-
37-
_driverProcess = await spawnDriverProcess();
38-
39-
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
40-
String error,
41-
) {
42-
print('[Webdriver][Error] $error');
43-
});
44-
45-
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
46-
String log,
47-
) {
48-
print('[Webdriver] $log');
49-
});
50-
}
51-
52-
@override
53-
Future<void> cleanup() async {
54-
_driverProcess.kill();
55-
}
56-
57-
@override
58-
Future<Browser> launchBrowserInstance(Uri url, {bool debug = false}) async {
59-
while (true) {
60-
try {
61-
final WebDriver driver = await createDriver(
62-
uri: driverUri,
63-
desired: <String, dynamic>{'browserName': packageTestRuntime.identifier},
64-
);
65-
return WebDriverBrowser(driver, url);
66-
} on SocketException {
67-
// Sometimes we may try to connect before the web driver port is ready.
68-
// So we should retry here. Note that if there was some issue with the
69-
// webdriver process, we may loop infinitely here, so we're relying on
70-
// the test timeout to kill us if it takes too long to connect.
71-
print('Failed to connect to webdriver process. Retrying in 100 ms');
72-
await Future<void>.delayed(const Duration(milliseconds: 100));
73-
} catch (exception) {
74-
rethrow;
75-
}
76-
}
77-
}
14+
/// Finds and returns an unused port on the test host in the local port range.
15+
Future<int> pickUnusedPort() async {
16+
// Use bind to allocate an unused port, then unbind from that port to
17+
// make it available for use.
18+
final ServerSocket socket = await ServerSocket.bind('localhost', 0);
19+
final int port = socket.port;
20+
await socket.close();
21+
return port;
7822
}
7923

24+
/// A browser managed through a WebDriver connection.
8025
class WebDriverBrowser extends Browser {
8126
WebDriverBrowser(this._driver, this._url) {
8227
_driver.get(_url);
@@ -104,7 +49,7 @@ class WebDriverBrowser extends Browser {
10449
_shouldStopActivating = true;
10550
await _activateLoopFuture;
10651

107-
await (await _driver.window).close();
52+
await _driver.quit();
10853
if (!_onExitCompleter.isCompleted) {
10954
_onExitCompleter.complete();
11055
}

0 commit comments

Comments
 (0)