Skip to content

Commit c1a6a9d

Browse files
authored
feat(dart_frog_cli): add devserver lifecycle (#730)
1 parent 92dffa2 commit c1a6a9d

File tree

5 files changed

+907
-797
lines changed

5 files changed

+907
-797
lines changed

packages/dart_frog_cli/lib/src/commands/dev/dev.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,22 @@ class DevCommand extends DartFrogCommand {
7070
_defaultDartVmServicePort;
7171
final generator = await _generator(dartFrogDevServerBundle);
7272

73-
final result = await _devServerRunnerBuilder(
73+
final devServer = _devServerRunnerBuilder(
7474
devServerBundleGenerator: generator,
7575
logger: logger,
7676
workingDirectory: cwd,
7777
port: port,
7878
dartVmServicePort: dartVmServicePort,
79-
).start();
79+
);
80+
81+
try {
82+
await devServer.start();
83+
} on DartFrogDevServerException catch (e) {
84+
logger.err(e.message);
85+
return ExitCode.software.code;
86+
}
87+
88+
final result = await devServer.exitCode;
8089

8190
return result.code;
8291
}

packages/dart_frog_cli/lib/src/dev_server_runner/dev_server_runner.dart

Lines changed: 156 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:convert';
23
import 'dart:io' as io;
34

@@ -39,9 +40,18 @@ final _dartVmServiceAlreadyInUseErrorRegex = RegExp(
3940
multiLine: true,
4041
);
4142

42-
// TODO(renancaraujo): Add reload and stop methods.
4343
/// {@template dev_server_runner}
4444
/// A class that manages a local development server process lifecycle.
45+
///
46+
/// The [DevServerRunner] is responsible for:
47+
/// - Generating the dev server runtime code.
48+
/// - Starting the dev server process.
49+
/// - Watching for file changes.
50+
/// - Restarting the dev server process when files change.
51+
/// - Stopping the dev server process when requested or under external
52+
/// circumstances (server process killed or watcher stopped).
53+
///
54+
/// After stopped, a [DevServerRunner] instance cannot be restarted.
4555
/// {@endtemplate}
4656
class DevServerRunner {
4757
/// {@macro dev_server_runner}
@@ -58,9 +68,7 @@ class DevServerRunner {
5868
@visibleForTesting io.ProcessSignal? sigint,
5969
@visibleForTesting ProcessStart? startProcess,
6070
@visibleForTesting ProcessRun? runProcess,
61-
@visibleForTesting Exit? exit,
6271
}) : _directoryWatcher = directoryWatcher ?? DirectoryWatcher.new,
63-
_exit = exit ?? io.exit,
6472
_isWindows = isWindows ?? io.Platform.isWindows,
6573
_sigint = sigint ?? io.ProcessSignal.sigint,
6674
_startProcess = startProcess ?? io.Process.start,
@@ -92,16 +100,34 @@ class DevServerRunner {
92100
final ProcessStart _startProcess;
93101
final ProcessRun _runProcess;
94102
final RestorableDirectoryGeneratorTargetBuilder _generatorTarget;
95-
final Exit _exit;
96103
final bool _isWindows;
97-
98104
final io.ProcessSignal _sigint;
99-
late final _target = _generatorTarget(
100-
io.Directory(path.join(workingDirectory.path, '.dart_frog')),
101-
logger: logger,
105+
106+
late final _generatedDirectory = io.Directory(
107+
path.join(workingDirectory.path, '.dart_frog'),
102108
);
109+
late final _target = _generatorTarget(_generatedDirectory, logger: logger);
103110

104111
var _isReloading = false;
112+
io.Process? _serverProcess;
113+
StreamSubscription<WatchEvent>? _watcherSubscription;
114+
115+
/// Whether the dev server is running.
116+
bool get isServerRunning => _serverProcess != null;
117+
118+
/// Whether the dev server is watching for file changes.
119+
bool get isWatching => _watcherSubscription != null;
120+
121+
/// Whether the dev server has been started and stopped.
122+
bool get isCompleted => _exitCodeCompleter.isCompleted;
123+
124+
final Completer<ExitCode> _exitCodeCompleter = Completer<ExitCode>();
125+
126+
/// A [Future] that completes when the dev server stops.
127+
///
128+
/// The [Future] will complete with the [ExitCode] indicating the conditions
129+
/// under which the dev server ended.
130+
Future<ExitCode> get exitCode => _exitCodeCompleter.future;
105131

106132
Future<void> _codegen() async {
107133
logger.detail('[codegen] running pre-gen...');
@@ -129,8 +155,15 @@ class DevServerRunner {
129155
logger.detail('[codegen] reload complete.');
130156
}
131157

132-
Future<void> _killProcess(io.Process process) async {
158+
// Internal method to kill the server process.
159+
// Make sure to call `stop` after calling this method to also stop the
160+
// watcher.
161+
Future<void> _killServerProcess() async {
133162
_isReloading = false;
163+
final process = _serverProcess;
164+
if (process == null) {
165+
return;
166+
}
134167
logger.detail('[process] killing process...');
135168
if (_isWindows) {
136169
logger.detail('[process] taskkill /F /T /PID ${process.pid}');
@@ -139,33 +172,73 @@ class DevServerRunner {
139172
logger.detail('[process] process.kill()...');
140173
process.kill();
141174
}
175+
_serverProcess = null;
142176
logger.detail('[process] killing process complete.');
143177
}
144178

145-
// TODO(renancaraujo): this method returns a future that completes when the
146-
// process is killed, but it should return a future that completes when the
147-
// process is finished starting.
148-
/// Starts the development server.
149-
Future<ExitCode> start() async {
150-
var isHotReloadingEnabled = false;
179+
// Internal method to cancel the watcher subscription.
180+
// Make sure to call `stop` after calling this method to also stop the
181+
// server process.
182+
Future<void> _cancelWatcherSubscription() async {
183+
if (!isWatching) {
184+
return;
185+
}
186+
logger.detail('[watcher] cancelling subscription...');
187+
await _watcherSubscription!.cancel();
188+
_watcherSubscription = null;
189+
logger.detail('[watcher] cancelling subscription complete.');
190+
}
191+
192+
/// Starts the development server and a [DirectoryWatcher] subscription
193+
/// that will regenerate the dev server code when files change.
194+
///
195+
/// This method will throw a [DartFrogDevServerException] if called while
196+
/// the dev server has been started.
197+
///
198+
/// This method will throw a [DartFrogDevServerException] if called after
199+
/// [stop] has been called.
200+
Future<void> start() async {
201+
if (isCompleted) {
202+
throw DartFrogDevServerException(
203+
'Cannot start a dev server after it has been stopped.',
204+
);
205+
}
206+
207+
if (isServerRunning) {
208+
throw DartFrogDevServerException(
209+
'Cannot start a dev server while already running.',
210+
);
211+
}
151212

152213
Future<void> serve() async {
214+
var isHotReloadingEnabled = false;
153215
final enableVmServiceFlag = '--enable-vm-service=$dartVmServicePort';
154216

217+
final serverDartFilePath = path.join(
218+
_generatedDirectory.absolute.path,
219+
'server.dart',
220+
);
221+
155222
logger.detail(
156-
'''[process] dart $enableVmServiceFlag ${path.join('.dart_frog', 'server.dart')}''',
223+
'''[process] dart $enableVmServiceFlag $serverDartFilePath''',
157224
);
158225

159-
final process = await _startProcess(
226+
final process = _serverProcess = await _startProcess(
160227
'dart',
161-
[enableVmServiceFlag, path.join('.dart_frog', 'server.dart')],
228+
[enableVmServiceFlag, serverDartFilePath],
162229
runInShell: true,
163230
);
164231

165232
// On Windows listen for CTRL-C and use taskkill to kill
166233
// the spawned process along with any child processes.
167234
// https://github.com/dart-lang/sdk/issues/22470
168-
if (_isWindows) _sigint.watch().listen((_) => _killProcess(process));
235+
if (_isWindows) {
236+
_sigint.watch().listen((_) {
237+
// Do not await on sigint
238+
_killServerProcess().ignore();
239+
stop();
240+
});
241+
}
169242

170243
var hasError = false;
171244
process.stderr.listen((_) async {
@@ -194,9 +267,11 @@ class DevServerRunner {
194267

195268
if ((!isHotReloadingEnabled && !isSDKWarning) ||
196269
isDartVMServiceAlreadyInUseError) {
197-
await _killProcess(process);
198-
logger.detail('[process] exit(1)');
199-
_exit(1);
270+
await _killServerProcess();
271+
const exitCode = ExitCode.software;
272+
logger.detail('[process] exit(${exitCode.code})');
273+
await stop(exitCode);
274+
return;
200275
}
201276

202277
await _target.rollback();
@@ -211,6 +286,15 @@ class DevServerRunner {
211286
if (shouldCacheSnapshot) _target.cacheLatestSnapshot();
212287
hasError = false;
213288
});
289+
290+
process.exitCode.then((code) async {
291+
if (isCompleted) return;
292+
logger
293+
..info('[process] Server process has been terminated')
294+
..detail('[process] exit($code)');
295+
await _killServerProcess();
296+
await stop(ExitCode.unavailable);
297+
}).ignore();
214298
}
215299

216300
final progress = logger.progress('Serving');
@@ -235,13 +319,59 @@ class DevServerRunner {
235319
}
236320

237321
final watcher = _directoryWatcher(path.join(cwdPath));
238-
final subscription = watcher.events
322+
_watcherSubscription = watcher.events
239323
.where(shouldReload)
240324
.debounce(Duration.zero)
241325
.listen((_) => _reload());
242326

243-
await subscription.asFuture<void>();
244-
await subscription.cancel();
245-
return ExitCode.success;
327+
_watcherSubscription!.asFuture<void>().then((_) async {
328+
await _cancelWatcherSubscription();
329+
await stop();
330+
}).catchError((_) async {
331+
await _cancelWatcherSubscription();
332+
await stop(ExitCode.software);
333+
}).ignore();
334+
}
335+
336+
/// Stops the development server and the watcher then
337+
/// completes [DevServerRunner.exitCode] with the given [exitCode].
338+
///
339+
/// If [exitCode] is not provided, it defaults to [ExitCode.success].
340+
///
341+
/// After calling [stop], the dev server cannot be restarted.
342+
///
343+
/// This can be called internally if the server process is killed or if the
344+
/// watcher stops watching.
345+
Future<void> stop([ExitCode exitCode = ExitCode.success]) async {
346+
if (isCompleted) {
347+
return;
348+
}
349+
350+
if (isWatching) {
351+
await _cancelWatcherSubscription();
352+
}
353+
if (isServerRunning) {
354+
await _killServerProcess();
355+
}
356+
357+
_exitCodeCompleter.complete(exitCode);
358+
}
359+
360+
/// Regenerates the dev server code and sends a hot reload signal to the
361+
/// server.
362+
Future<void> reload() async {
363+
if (isCompleted || !isServerRunning || _isReloading) return;
364+
return _reload();
246365
}
247366
}
367+
368+
/// {@template dart_frog_dev_server_exception}
369+
/// Exception thrown when the dev server fails to start.
370+
/// {@endtemplate}
371+
class DartFrogDevServerException implements Exception {
372+
/// {@macro dart_frog_dev_server_exception}
373+
DartFrogDevServerException(this.message);
374+
375+
/// The exception message.
376+
final String message;
377+
}

packages/dart_frog_cli/lib/src/runtime_compatibility.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class DartFrogCompatibilityException implements Exception {
1515
/// {@macro dart_frog_compatibility_exception}
1616
const DartFrogCompatibilityException(this.message);
1717

18-
/// The error message.
18+
/// The exception message.
1919
final String message;
2020

2121
@override

packages/dart_frog_cli/test/src/commands/dev/dev_test.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ void main() {
6565
});
6666

6767
test('run the dev server with the given parameters', () async {
68-
when(() => runner.start()).thenAnswer(
69-
(invocation) => Future.value(ExitCode.success),
68+
when(() => runner.start()).thenAnswer((_) => Future.value());
69+
when(() => runner.exitCode).thenAnswer(
70+
(_) => Future.value(ExitCode.success),
7071
);
7172

7273
when(() => argResults['port']).thenReturn('1234');
@@ -126,11 +127,36 @@ void main() {
126127
logger: logger,
127128
)..testArgResults = argResults;
128129

129-
when(() => runner.start()).thenAnswer(
130-
(invocation) => Future.value(ExitCode.software),
130+
when(() => runner.start()).thenAnswer((_) => Future.value());
131+
when(() => runner.exitCode).thenAnswer(
132+
(_) => Future.value(ExitCode.unavailable),
131133
);
132134

135+
await expectLater(command.run(), completion(ExitCode.unavailable.code));
136+
});
137+
138+
test('fails if dev server runner fails on start', () async {
139+
final command = DevCommand(
140+
generator: (_) async => generator,
141+
ensureRuntimeCompatibility: (_) {},
142+
devServerRunnerBuilder: ({
143+
required logger,
144+
required port,
145+
required devServerBundleGenerator,
146+
required dartVmServicePort,
147+
required workingDirectory,
148+
}) {
149+
return runner;
150+
},
151+
logger: logger,
152+
)..testArgResults = argResults;
153+
154+
when(() => runner.start()).thenAnswer((_) async {
155+
throw DartFrogDevServerException('oops');
156+
});
157+
133158
await expectLater(command.run(), completion(ExitCode.software.code));
159+
verify(() => logger.err('oops')).called(1);
134160
});
135161
});
136162
}

0 commit comments

Comments
 (0)