Skip to content

Commit f808c53

Browse files
Statyk7claudemarcossevilla
authored
fix: add timeout to prevent bootstrapping from hanging indefinitely (#1548)
* fix: add timeout to bootstrapping and pub get to prevent indefinite hangs When connected to a network without internet access, the bootstrapping step (Mason hook's internal dart pub get) and subsequent pub get commands can hang indefinitely. This adds a 120-second timeout that surfaces a clear error message instead. Closes #1547 https://claude.ai/code/session_01JoFEgFpRkH5idHrQFNy6xP * fix: analyzer error lints * fix: add timeout as param * fix: lints --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Marcos Sevilla <me@marcossevilla.dev>
1 parent ad52603 commit f808c53

7 files changed

Lines changed: 149 additions & 2 deletions

File tree

lib/src/cli/cli.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,41 @@ class _ProcessOverridesScope extends ProcessOverrides {
7979
/// Abstraction for running commands via command-line.
8080
class _Cmd {
8181
/// Runs the specified [cmd] with the provided [args].
82+
///
83+
/// If [timeout] is provided, the process will be terminated after the
84+
/// specified duration with a [ProcessException].
8285
static Future<ProcessResult> run(
8386
String cmd,
8487
List<String> args, {
8588
required Logger logger,
8689
bool throwOnError = true,
8790
String? workingDirectory,
91+
Duration? timeout,
8892
}) async {
8993
logger.detail('Running: $cmd with $args');
9094
final runProcess = ProcessOverrides.current?.runProcess ?? Process.run;
91-
final result = await runProcess(
95+
final processFuture = runProcess(
9296
cmd,
9397
args,
9498
runInShell: true,
9599
workingDirectory: workingDirectory,
96100
);
101+
final ProcessResult result;
102+
if (timeout != null) {
103+
try {
104+
result = await processFuture.timeout(timeout);
105+
} on TimeoutException {
106+
throw ProcessException(
107+
cmd,
108+
args,
109+
'Timed out after ${timeout.inSeconds} seconds. '
110+
'Check your internet connection.',
111+
-1,
112+
);
113+
}
114+
} else {
115+
result = await processFuture;
116+
}
97117
logger
98118
..detail('stdout:\n${result.stdout}')
99119
..detail('stderr:\n${result.stderr}');

lib/src/cli/dart_cli.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
part of 'cli.dart';
22

3+
/// Default timeout for pub get operations.
4+
///
5+
/// When connected to a network without internet access, pub get can hang
6+
/// indefinitely. This timeout ensures the CLI surfaces a clear error instead.
7+
const _pubGetTimeout = Duration(seconds: 120);
8+
39
/// Dart CLI
410
class Dart {
511
/// Determine whether dart is installed.
@@ -18,6 +24,7 @@ class Dart {
1824
String cwd = '.',
1925
bool recursive = false,
2026
Set<String> ignore = const {},
27+
Duration timeout = _pubGetTimeout,
2128
}) async {
2229
final initialCwd = cwd;
2330

@@ -45,6 +52,7 @@ class Dart {
4552
['pub', 'get', '--no-example'],
4653
workingDirectory: cwd,
4754
logger: logger,
55+
timeout: timeout,
4856
);
4957
} finally {
5058
installProgress.complete();

lib/src/cli/flutter_cli.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class Flutter {
157157
String cwd = '.',
158158
bool recursive = false,
159159
Set<String> ignore = const {},
160+
Duration timeout = _pubGetTimeout,
160161
}) async {
161162
final initialCwd = cwd;
162163

@@ -184,6 +185,7 @@ class Flutter {
184185
['pub', 'get', '--no-example'],
185186
workingDirectory: cwd,
186187
logger: logger,
188+
timeout: timeout,
187189
);
188190
} finally {
189191
installProgress.complete();

lib/src/commands/create/commands/create_subcommand.dart

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ abstract class CreateSubCommand extends Command<int> {
213213
return result;
214214
}
215215

216+
/// Timeout for the Mason hook's internal `dart pub get` during bootstrapping.
217+
///
218+
/// When connected to a network without internet access, this can hang
219+
/// indefinitely. This timeout ensures the CLI surfaces a clear error instead.
220+
@visibleForTesting
221+
static const preGenTimeout = Duration(seconds: 120);
222+
216223
/// Invoked by [run] to create the project, contains the logic for using
217224
/// the template vars obtained by [getTemplateVars] to generate the project
218225
/// from the [generator] and [template].
@@ -222,7 +229,18 @@ abstract class CreateSubCommand extends Command<int> {
222229
final generateProgress = logger.progress('Bootstrapping');
223230
final target = DirectoryGeneratorTarget(outputDirectory);
224231

225-
await generator.hooks.preGen(vars: vars, onVarsChanged: (v) => vars = v);
232+
try {
233+
await generator.hooks
234+
.preGen(vars: vars, onVarsChanged: (v) => vars = v)
235+
.timeout(preGenTimeout);
236+
} on TimeoutException {
237+
generateProgress.fail('Timed out waiting for hook dependencies.');
238+
logger.err(
239+
'Bootstrapping timed out after ${preGenTimeout.inSeconds} seconds.\n'
240+
'Check your internet connection and try again.',
241+
);
242+
return ExitCode.unavailable.code;
243+
}
226244
final files = await generator.generate(target, vars: vars, logger: logger);
227245
generateProgress.complete('Generated ${files.length} file(s)');
228246

test/src/cli/dart_cli_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:mason/mason.dart';
24
import 'package:mocktail/mocktail.dart';
35
import 'package:path/path.dart' as p;
@@ -284,6 +286,34 @@ void main() {
284286
runProcess: process.run,
285287
);
286288
});
289+
290+
test('throws ProcessException when pub get times out', () async {
291+
when(
292+
() => process.run(
293+
'dart',
294+
any(that: contains('pub')),
295+
runInShell: any(named: 'runInShell'),
296+
workingDirectory: any(named: 'workingDirectory'),
297+
),
298+
).thenAnswer((_) => Completer<ProcessResult>().future);
299+
300+
await ProcessOverrides.runZoned(
301+
() => expectLater(
302+
Dart.pubGet(
303+
logger: logger,
304+
timeout: const Duration(milliseconds: 100),
305+
),
306+
throwsA(
307+
isA<ProcessException>().having(
308+
(e) => e.message,
309+
'message',
310+
contains('Timed out'),
311+
),
312+
),
313+
),
314+
runProcess: process.run,
315+
);
316+
});
287317
});
288318

289319
group('.applyFixes', () {

test/src/cli/flutter_cli_test.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,34 @@ void main() {
301301
runProcess: process.run,
302302
);
303303
});
304+
305+
test('throws ProcessException when pub get times out', () async {
306+
when(
307+
() => process.run(
308+
'flutter',
309+
any(that: contains('pub')),
310+
runInShell: any(named: 'runInShell'),
311+
workingDirectory: any(named: 'workingDirectory'),
312+
),
313+
).thenAnswer((_) => Completer<ProcessResult>().future);
314+
315+
await ProcessOverrides.runZoned(
316+
() => expectLater(
317+
Flutter.pubGet(
318+
logger: logger,
319+
timeout: const Duration(milliseconds: 100),
320+
),
321+
throwsA(
322+
isA<ProcessException>().having(
323+
(e) => e.message,
324+
'message',
325+
contains('Timed out'),
326+
),
327+
),
328+
),
329+
runProcess: process.run,
330+
);
331+
});
304332
});
305333
});
306334

test/src/commands/create/create_subcommand_test.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:io';
23

34
import 'package:args/args.dart';
@@ -587,6 +588,46 @@ See https://dart.dev/tools/pub/pubspec#name for more information.'''),
587588
).called(1);
588589
});
589590
});
591+
592+
test(
593+
'returns unavailable exit code when preGen times out',
594+
() async {
595+
when(
596+
() => hooks.preGen(
597+
vars: any(named: 'vars'),
598+
onVarsChanged: any(named: 'onVarsChanged'),
599+
),
600+
).thenAnswer((_) => Completer<void>().future);
601+
602+
final result = await runner.run([
603+
'create_subcommand',
604+
'test_project',
605+
]);
606+
607+
expect(result, equals(ExitCode.unavailable.code));
608+
verify(() => progress.fail(any())).called(1);
609+
verify(
610+
() => logger.err(
611+
any(
612+
that: contains(
613+
'Bootstrapping timed out after '
614+
'${CreateSubCommand.preGenTimeout.inSeconds} seconds.',
615+
),
616+
),
617+
),
618+
).called(1);
619+
verifyNever(
620+
() => generator.generate(
621+
any(),
622+
vars: any(named: 'vars'),
623+
logger: any(named: 'logger'),
624+
),
625+
);
626+
},
627+
timeout: Timeout(
628+
CreateSubCommand.preGenTimeout + const Duration(seconds: 5),
629+
),
630+
);
590631
});
591632
});
592633
group('OrgName', () {

0 commit comments

Comments
 (0)