diff --git a/lib/src/services/process_service.dart b/lib/src/services/process_service.dart index 3d30762ca..53b933826 100644 --- a/lib/src/services/process_service.dart +++ b/lib/src/services/process_service.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import '../utils/exceptions.dart'; import 'base_service.dart'; class ProcessService extends ContextualService { @@ -64,18 +66,41 @@ class ProcessService extends ContextualService { return processResult; } - final process = await Process.start( - command, - args, - workingDirectory: workingDirectory, - environment: environment, - runInShell: runInShell, - mode: ProcessStartMode.inheritStdio, - ); + StreamSubscription? sigintSubscription; + var interrupted = false; + if (!Platform.isWindows && context.stdinHasTerminal) { + sigintSubscription = ProcessSignal.sigint.watch().listen((_) { + interrupted = true; + }); + } + + late final Process process; + late final int processExitCode; + try { + process = await Process.start( + command, + args, + workingDirectory: workingDirectory, + environment: environment, + runInShell: runInShell, + mode: ProcessStartMode.inheritStdio, + ); + + if (interrupted) { + process.kill(ProcessSignal.sigint); + } + processExitCode = await process.exitCode; + } finally { + await sigintSubscription?.cancel(); + } + + if (interrupted) { + throw ForceExit('', 128 + ProcessSignal.sigint.signalNumber); + } processResult = ProcessResult( process.pid, - await process.exitCode, + processExitCode, null, null, ); diff --git a/lib/src/workflows/setup_flutter.workflow.dart b/lib/src/workflows/setup_flutter.workflow.dart index d816ef07e..9020f7f41 100644 --- a/lib/src/workflows/setup_flutter.workflow.dart +++ b/lib/src/workflows/setup_flutter.workflow.dart @@ -1,5 +1,6 @@ import '../models/cache_flutter_version_model.dart'; import '../services/flutter_service.dart'; +import '../utils/exceptions.dart'; import 'workflow.dart'; class SetupFlutterWorkflow extends Workflow { @@ -18,6 +19,8 @@ class SetupFlutterWorkflow extends Workflow { logger ..info() ..success('Flutter SDK: ${version.printFriendlyName} is setup'); + } on ForceExit { + rethrow; } on Exception catch (_) { logger.err('Failed to setup Flutter SDK'); diff --git a/test/src/services/process_service_sigint_pty_test.dart b/test/src/services/process_service_sigint_pty_test.dart new file mode 100644 index 000000000..d4b2d84a4 --- /dev/null +++ b/test/src/services/process_service_sigint_pty_test.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:fvm/src/models/config_model.dart'; +import 'package:fvm/src/services/process_service.dart'; +import 'package:fvm/src/utils/context.dart'; +import 'package:fvm/src/utils/exceptions.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _roleEnvironmentKey = 'FVM_PROCESS_SERVICE_PTY_ROLE'; +const _parentRole = 'parent'; +const _childRole = 'child'; +const _markerPrefix = 'FVM_PTY_TEST:'; + +String _requirePython3() { + try { + final result = Process.runSync('python3', const [ + '-c', + 'import pty, termios', + ]); + if (result.exitCode == 0) return 'python3'; + } on ProcessException { + // Report the same focused prerequisite error as a failed module import. + } + + fail( + 'python3 with the POSIX pty and termios modules is required to run ' + 'the ProcessService PTY test.', + ); +} + +// The test runner launches this file through a Python-owned PTY. The same file +// then acts as the FVM parent and the delayed, signal-aware Flutter child. +Future main() async { + final role = Platform.environment[_roleEnvironmentKey]; + if (role == _parentRole) { + await _runParent(); + return; + } + if (role == _childRole) { + await _runChild(); + return; + } + + test( + 'waits for child cleanup before exiting after terminal SIGINT', + () async { + final repositoryRoot = Directory.current.path; + final result = await Process.run( + _requirePython3(), + [ + p.join( + repositoryRoot, + 'test', + 'support_assets', + 'process_service_sigint_pty.py', + ), + '--dart', + Platform.resolvedExecutable, + '--harness', + p.join( + repositoryRoot, + 'test', + 'src', + 'services', + 'process_service_sigint_pty_test.dart', + ), + '--cwd', + repositoryRoot, + ], + workingDirectory: repositoryRoot, + ); + + expect( + result.exitCode, + 0, + reason: '${result.stderr}\n${result.stdout}', + ); + + final report = + jsonDecode(result.stdout as String) as Map; + expect(report['forceExitAfterCleanup'], isTrue); + expect(report['childRestoredTerminal'], isTrue); + expect(report['childGone'], isTrue); + expect(report['processGroupGone'], isTrue); + expect(report['parentExitCode'], 130); + }, + skip: Platform.isWindows ? 'POSIX pseudo-terminal required' : false, + timeout: const Timeout(Duration(minutes: 2)), + ); +} + +Future _runParent() async { + final tempDirectory = await Directory.systemTemp.createTemp( + 'fvm_process_service_pty_', + ); + + try { + final context = FvmContext.create( + debugLabel: 'process-service-pty-parent', + configOverrides: AppConfig( + cachePath: p.join(tempDirectory.path, 'cache'), + gitCachePath: p.join(tempDirectory.path, 'cache.git'), + useGitCache: false, + privilegedAccess: false, + disableUpdateCheck: true, + ), + workingDirectoryOverride: Directory.current.path, + appConfigPath: p.join(tempDirectory.path, 'config.json'), + isTest: false, + ); + final productionContext = !context.isTest && context.stdinHasTerminal; + stdout.writeln('${_markerPrefix}PARENT_CONTEXT:$productionContext'); + await stdout.flush(); + + if (!productionContext) { + exitCode = 2; + return; + } + + try { + final result = await context.get().run( + Platform.resolvedExecutable, + args: [Platform.script.toFilePath()], + environment: { + ...Platform.environment, + _roleEnvironmentKey: _childRole, + }, + echoOutput: true, + throwOnError: false, + ); + stdout.writeln( + '${_markerPrefix}PARENT_UNEXPECTED_RESULT:${result.exitCode}', + ); + exitCode = 3; + } on ForceExit catch (error) { + stdout.writeln('${_markerPrefix}PARENT_FORCE_EXIT:${error.exitCode}'); + exitCode = error.exitCode; + } + await stdout.flush(); + } finally { + await tempDirectory.delete(recursive: true); + } +} + +Future _runChild() async { + final interrupted = Completer(); + final subscription = ProcessSignal.sigint.watch().listen((_) { + if (!interrupted.isCompleted) interrupted.complete(); + }); + + try { + stdout.writeln('${_markerPrefix}CHILD_READY:$pid'); + await stdout.flush(); + await interrupted.future; + + stdout.writeln('${_markerPrefix}CHILD_SIGINT'); + await stdout.flush(); + await Future.delayed(const Duration(milliseconds: 250)); + + if (!stdin.hasTerminal) { + stdout.writeln('${_markerPrefix}CHILD_NO_TERMINAL'); + exitCode = 4; + return; + } + + try { + stdin.lineMode = true; + stdin.echoMode = true; + } on StdinException catch (error) { + stdout.writeln('${_markerPrefix}CHILD_TTY_ERROR:$error'); + exitCode = 5; + return; + } + + stdout.writeln('${_markerPrefix}CHILD_CLEANUP_DONE'); + await stdout.flush(); + exitCode = 130; + } finally { + await subscription.cancel(); + } +} diff --git a/test/src/workflows/setup_flutter.workflow_test.dart b/test/src/workflows/setup_flutter.workflow_test.dart new file mode 100644 index 000000000..17ac3fda7 --- /dev/null +++ b/test/src/workflows/setup_flutter.workflow_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:fvm/src/models/cache_flutter_version_model.dart'; +import 'package:fvm/src/models/flutter_version_model.dart'; +import 'package:fvm/src/services/flutter_service.dart'; +import 'package:fvm/src/services/logger_service.dart'; +import 'package:fvm/src/utils/exceptions.dart'; +import 'package:fvm/src/workflows/setup_flutter.workflow.dart'; +import 'package:test/test.dart'; + +import '../../testing_utils.dart'; + +class InterruptedSetupFlutterService extends FlutterService { + InterruptedSetupFlutterService(super.context); + + @override + Future setup(CacheFlutterVersion version) async { + throw const ForceExit('', 130); + } +} + +void main() { + test('propagates interruption without logging setup failure', () async { + final tempDirs = TempDirectoryTracker(); + addTearDown(tempDirs.cleanUp); + + final context = TestFactory.context( + generators: { + FlutterService: (context) => InterruptedSetupFlutterService(context), + }, + ); + final version = CacheFlutterVersion.fromVersion( + FlutterVersion.parse('3.10.0'), + directory: tempDirs.create().path, + ); + + await expectLater( + () => SetupFlutterWorkflow(context)(version), + throwsA( + isA().having( + (error) => error.exitCode, + 'exitCode', + 130, + ), + ), + ); + + final logger = context.get(); + expect( + logger.outputs.any( + (message) => message.contains('Failed to setup Flutter SDK'), + ), + isFalse, + ); + expect( + logger.outputs.any((message) => message.contains('is setup')), + isFalse, + ); + }); +} diff --git a/test/support_assets/process_service_sigint_pty.py b/test/support_assets/process_service_sigint_pty.py new file mode 100755 index 000000000..168330a62 --- /dev/null +++ b/test/support_assets/process_service_sigint_pty.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Drive the ProcessService SIGINT harness through an isolated POSIX PTY. + +Dart's standard library cannot create a controlling PTY, so this helper owns +only the POSIX terminal setup and reports observations back to the Dart test. +""" + +import argparse +import errno +import json +import os +import pty +import re +import select +import signal +import sys +import termios +import time + +ROLE_ENVIRONMENT_KEY = "FVM_PROCESS_SERVICE_PTY_ROLE" +PARENT_CONTEXT = b"FVM_PTY_TEST:PARENT_CONTEXT:true" +CHILD_READY_PATTERN = re.compile(rb"FVM_PTY_TEST:CHILD_READY:(\d+)") +CHILD_SIGINT = b"FVM_PTY_TEST:CHILD_SIGINT" +CHILD_CLEANUP_DONE = b"FVM_PTY_TEST:CHILD_CLEANUP_DONE" +PARENT_FORCE_EXIT = b"FVM_PTY_TEST:PARENT_FORCE_EXIT:130" + + +def _read_once(master_fd, output, timeout): + readable, _, _ = select.select([master_fd], [], [], timeout) + if not readable: + return + try: + output.extend(os.read(master_fd, 65536)) + except OSError as error: + if error.errno != errno.EIO: + raise + + +def _wait_for(master_fd, output, marker, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if marker in output: + return + remaining = max(0.0, deadline - time.monotonic()) + _read_once(master_fd, output, min(0.05, remaining)) + if marker not in output: + raise RuntimeError(f"Timed out waiting for {marker!r}") + + +def _process_exists(process_id): + try: + os.kill(process_id, 0) + return True + except ProcessLookupError: + return False + + +def _process_group_exists(process_group_id): + try: + os.killpg(process_group_id, 0) + return True + except ProcessLookupError: + return False + + +def _wait_until(predicate, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.02) + return predicate() + + +def _configure_terminal(master_fd): + attributes = termios.tcgetattr(master_fd) + attributes[0] &= ~termios.ICRNL + attributes[3] &= ~(termios.ICANON | termios.ECHO) + attributes[3] |= termios.ISIG + attributes[6][termios.VINTR] = b"\x03" + termios.tcsetattr(master_fd, termios.TCSANOW, attributes) + + +def _run(args): + parent_pid, master_fd = pty.fork() + if parent_pid == 0: + environment = os.environ.copy() + environment[ROLE_ENVIRONMENT_KEY] = "parent" + os.chdir(args.cwd) + os.execve(args.dart, [args.dart, args.harness], environment) + + output = bytearray() + parent_reaped = False + parent_status = None + + try: + os.set_blocking(master_fd, False) + _configure_terminal(master_fd) + + _wait_for(master_fd, output, PARENT_CONTEXT, 20) + if os.tcgetpgrp(master_fd) != parent_pid: + raise RuntimeError("Dart parent is not the PTY foreground process group") + + _wait_for(master_fd, output, b"FVM_PTY_TEST:CHILD_READY:", 20) + child_match = CHILD_READY_PATTERN.search(output) + if child_match is None: + raise RuntimeError("Could not parse the fake Flutter child PID") + child_pid = int(child_match.group(1)) + + os.write(master_fd, b"\x03") + _wait_for(master_fd, output, CHILD_SIGINT, 5) + + _wait_for(master_fd, output, CHILD_CLEANUP_DONE, 5) + terminal_attributes = termios.tcgetattr(master_fd) + child_restored_terminal = bool( + terminal_attributes[3] & termios.ICANON + ) and bool(terminal_attributes[3] & termios.ECHO) + + _wait_for(master_fd, output, PARENT_FORCE_EXIT, 5) + cleanup_position = output.index(CHILD_CLEANUP_DONE) + force_exit_position = output.index(PARENT_FORCE_EXIT) + force_exit_after_cleanup = cleanup_position < force_exit_position + + deadline = time.monotonic() + 5 + while not parent_reaped and time.monotonic() < deadline: + _read_once(master_fd, output, 0.05) + waited_pid, waited_status = os.waitpid(parent_pid, os.WNOHANG) + if waited_pid != 0: + parent_reaped = True + parent_status = waited_status + + if not parent_reaped or parent_status is None: + raise RuntimeError("Timed out waiting for the Dart parent to exit") + + child_gone = _wait_until(lambda: not _process_exists(child_pid), 2) + process_group_gone = _wait_until( + lambda: not _process_group_exists(parent_pid), + 2, + ) + parent_exit_code = ( + os.WEXITSTATUS(parent_status) if os.WIFEXITED(parent_status) else None + ) + + return { + "forceExitAfterCleanup": force_exit_after_cleanup, + "childRestoredTerminal": child_restored_terminal, + "childGone": child_gone, + "processGroupGone": process_group_gone, + "parentExitCode": parent_exit_code, + } + except Exception as error: + transcript = repr(bytes(output)) + raise RuntimeError(f"{error}\nPTY transcript: {transcript}") from error + finally: + try: + os.killpg(parent_pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + os.close(master_fd) + except OSError: + pass + if not parent_reaped: + try: + os.waitpid(parent_pid, 0) + except ChildProcessError: + pass + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--dart", required=True) + parser.add_argument("--harness", required=True) + parser.add_argument("--cwd", required=True) + args = parser.parse_args() + + try: + report = _run(args) + except Exception as error: + print(error, file=sys.stderr) + return 1 + + print(json.dumps(report, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())