Skip to content

Commit d8ceb6a

Browse files
committed
Detect build readiness from webdev stdout
Switches WebdevServer from ProcessStartMode.inheritStdio to ProcessStartMode.normal so that webdev stdout can be monitored. Exposes a stdoutLines broadcast stream on WebdevServer; stderr continues to be forwarded to the parent process. ServeCommand listens to stdoutLines, forwarding each line to stdout and watching for build_runner's completion signal ('Built with build_runner' / 'Failed to build with build_runner'). Once detected, it emits '[INFO] Succeeded after N seconds' on its own line. Also adds a test verifying the build-complete signal appears in webdev stdout before the HTTP server becomes responsive.
1 parent 3f16c75 commit d8ceb6a

5 files changed

Lines changed: 145 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Unreleased
2+
3+
- #67 Detect build readiness from webdev stdout for compatibility with Dart 3 / newer webdev versions.
4+
15
## 0.1.12
26

37
- Update ranges of dependencies so that in Dart 3 we can resolve to analyzer 6, while still working with Dart 2.19. https://github.com/Workiva/webdev_proxy/pull/46

lib/src/serve_command.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class ServeCommand extends Command<int> {
149149
for (final dir in portsToServeByDir.keys) dir: await findUnusedPort()
150150
};
151151

152+
final startupTimer = Stopwatch()..start();
153+
152154
// Start the underlying `webdev serve` process.
153155
webdevServer = await WebdevServer.start([
154156
if (hostname != 'localhost') '--hostname=$hostname',
@@ -157,6 +159,9 @@ class ServeCommand extends Command<int> {
157159
'$dir:${portsToProxyByDir[dir]}',
158160
]);
159161

162+
// Forward webdev stdout so users see build output.
163+
webdevServer.stdoutLines.listen((line) => stdout.writeln(line));
164+
160165
// Stop proxies and exit if webdev exits.
161166
unawaited(webdevServer.exitCode.then((code) {
162167
if (!interruptReceived && !proxiesFailed) {
@@ -186,6 +191,30 @@ class ServeCommand extends Command<int> {
186191
}
187192
}
188193

194+
if (!proxiesFailed) {
195+
// Wait for build_runner to report completion via webdev's stdout.
196+
// build_runner always emits one of these lines when the initial build
197+
// finishes, regardless of whether -v/verbose is set.
198+
const buildCompleteStrings = [
199+
'Built with build_runner',
200+
'Failed to build with build_runner',
201+
];
202+
await Future.any([
203+
webdevServer.stdoutLines.firstWhere(
204+
(line) => buildCompleteStrings.any(line.contains),
205+
),
206+
exitCodeCompleter.future,
207+
]);
208+
209+
if (!exitCodeCompleter.isCompleted) {
210+
final elapsedSeconds =
211+
(startupTimer.elapsedMilliseconds / 1000).toStringAsFixed(1);
212+
stdout.writeln('[INFO] Succeeded after $elapsedSeconds seconds');
213+
}
214+
}
215+
216+
startupTimer.stop();
217+
189218
return exitCodeCompleter.future;
190219
}
191220
}

lib/src/webdev_server.dart

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import 'dart:async';
16+
import 'dart:convert';
1617
import 'dart:io';
1718

1819
import 'package:webdev_proxy/src/logging.dart';
@@ -22,7 +23,14 @@ class WebdevServer {
2223
/// The `webdev serve ...` process.
2324
final Process _process;
2425

25-
WebdevServer._(this._process);
26+
/// A broadcast stream of lines from the `webdev serve` process stdout.
27+
///
28+
/// Useful for detecting build lifecycle events such as
29+
/// `'Built with build_runner'` (success) or
30+
/// `'Failed to build with build_runner'` (failure).
31+
final Stream<String> stdoutLines;
32+
33+
WebdevServer._(this._process, this.stdoutLines);
2634

2735
/// Returns a Future which completes with the exit code of the underlying
2836
/// `webdev serve` process.
@@ -37,15 +45,35 @@ class WebdevServer {
3745

3846
/// Starts a `webdev serve` process with the given [args] and returns a
3947
/// [WebdevServer] abstraction over said process.
40-
static Future<WebdevServer> start(List<String> args,
41-
{ProcessStartMode mode = ProcessStartMode.inheritStdio}) async {
42-
final webdevArgs = ['pub', 'global', 'run', 'webdev', 'serve', ...args];
48+
static Future<WebdevServer> start(List<String> args) async {
49+
final webdevArgs = [
50+
'pub',
51+
'global',
52+
'run',
53+
'webdev',
54+
'serve',
55+
...args,
56+
];
4357
log.fine('Running `dart ${webdevArgs.join(' ')}');
4458
final process = await Process.start(
4559
'dart',
4660
webdevArgs,
47-
mode: mode,
61+
mode: ProcessStartMode.normal,
4862
);
49-
return WebdevServer._(process);
63+
64+
// Forward stderr so error messages remain visible.
65+
process.stderr.listen((data) => stderr.add(data));
66+
67+
// Split stdout into lines and expose as a broadcast stream. The listen()
68+
// call here is essential: it drains the process stdout pipe (preventing
69+
// backpressure deadlock) regardless of whether anyone subscribes to the
70+
// broadcast stream.
71+
final controller = StreamController<String>.broadcast();
72+
process.stdout
73+
.transform(utf8.decoder)
74+
.transform(const LineSplitter())
75+
.listen(controller.add, onDone: controller.close, cancelOnError: false);
76+
77+
return WebdevServer._(process, controller.stream);
5078
}
5179
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2019 Workiva Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@TestOn('vm')
16+
import 'dart:async';
17+
import 'dart:convert';
18+
import 'dart:io';
19+
20+
import 'package:http/http.dart' as http;
21+
import 'package:test/test.dart';
22+
import 'package:webdev_proxy/src/port_utils.dart';
23+
import 'package:webdev_proxy/src/webdev_proc_utils.dart';
24+
import 'package:webdev_proxy/src/webdev_server.dart';
25+
26+
import 'util.dart';
27+
28+
// Matches the build-complete patterns that build_runner emits to webdev stdout.
29+
final _buildCompletePattern =
30+
RegExp(r'Built with build_runner|Failed to build with build_runner');
31+
32+
// Strips ANSI escape sequences so output is readable in test logs.
33+
String _stripAnsi(String s) => s.replaceAll(RegExp(r'\x1b\[[0-9;]*[a-zA-Z]'), '');
34+
35+
void main() {
36+
late WebdevServer webdevServer;
37+
38+
setUpAll(() async {
39+
await activateWebdev(webdevCompatibility.toString());
40+
});
41+
42+
tearDown(() async {
43+
await webdevServer.close();
44+
});
45+
46+
test(
47+
'WebdevServer.stdoutLines emits build-complete signal before HTTP server '
48+
'responds', () async {
49+
final port = await findUnusedPort();
50+
webdevServer = await WebdevServer.start(['test:$port']);
51+
52+
final allLines = <String>[];
53+
webdevServer.stdoutLines.listen((line) => allLines.add(line));
54+
55+
String? matchedLine;
56+
try {
57+
matchedLine = await webdevServer.stdoutLines
58+
.firstWhere(_buildCompletePattern.hasMatch)
59+
.timeout(const Duration(seconds: 90));
60+
} on TimeoutException {
61+
fail('Timed out waiting for build-complete signal.\n'
62+
'Lines seen:\n${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
63+
} on StateError {
64+
fail('Build-complete signal never appeared — webdev exited early.\n'
65+
'Lines seen:\n${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
66+
}
67+
68+
expect(_stripAnsi(matchedLine), matches(_buildCompletePattern),
69+
reason: 'Matched line should contain the build-complete pattern');
70+
71+
// Verify the server is also HTTP-responsive at this point.
72+
final response = await http
73+
.get(Uri.parse('http://localhost:$port/'))
74+
.timeout(const Duration(seconds: 10));
75+
expect(response.statusCode, isNot(equals(0)));
76+
});
77+
}

test/webdev_server_test.dart

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
// limitations under the License.
1414

1515
@TestOn('vm')
16-
import 'dart:io';
17-
1816
import 'package:http/http.dart' as http;
1917
import 'package:test/test.dart';
2018
import 'package:webdev_proxy/src/port_utils.dart';
@@ -36,8 +34,7 @@ void main() async {
3634

3735
test('Serves a directory', () async {
3836
final port = await findUnusedPort();
39-
webdevServer =
40-
await WebdevServer.start(['test:$port'], mode: ProcessStartMode.normal);
37+
webdevServer = await WebdevServer.start(['test:$port']);
4138

4239
// We don't have a good way of knowing when the `webdev serve` process has
4340
// started listening on the port, so we send a request periodically until it

0 commit comments

Comments
 (0)