arblant
firefox-vpn-client / app / lib / core / core_process.dart
core_process.dart212 lines · 7.6 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'core_api.dart';
import 'ports.dart';

/// Runs the core as a child process for as long as the app runs (desktop).
///
/// The core starts with `-app`: no tray, no console, and it exits when its
/// stdin closes, so it never outlives the app, even after a crash. When a
/// core already serves the data folder (started by hand, or another copy of
/// the app), the app uses it and leaves it running on quit.
class CoreProcess {
  CoreProcess({required this.executable, required this.dataDir});

  final String executable;
  final String dataDir;

  Process? _process;
  bool _running = false;
  DateTime? _startedAt;

  /// The core stopped soon after starting; it is not started again until
  /// [retryNow], so a lasting problem (a taken port) does not loop.
  bool _failedFast = false;
  final _stderr = <String>[];

  /// The core's last structured error: `app-error: <code> <detail>` on
  /// stderr (core/doc/OPERATOR.md, desktop app mode).
  ({String code, String detail})? _appError;

  /// Whether this app started the core that is running now.
  bool get owned => _process != null && _running;

  /// The default executable, or `--core=<path>`: in the `core` folder
  /// beside the app (Windows, Linux), so the app is the only program there;
  /// beside the app's executable inside the bundle on macOS.
  static String executableFrom(List<String> args) {
    for (final a in args) {
      if (a.startsWith('--core=')) return a.substring('--core='.length);
    }
    final sep = Platform.pathSeparator;
    final dir = File(Platform.resolvedExecutable).parent.path;
    if (Platform.isMacOS) return '$dir${sep}firefox-vpn-core';
    final name = Platform.isWindows
        ? 'firefox-vpn-core.exe'
        : 'firefox-vpn-core';
    return '$dir${sep}core$sep$name';
  }

  /// Opens a client of the core, starting the core when none answers. It
  /// throws [CoreUnavailable] until the core is ready.
  Future<CoreApi> connect() async {
    // Windows takes about two seconds to refuse a connection to a loopback
    // port nobody listens on; a bind test answers at once. Nothing there:
    // start the core (or wait for the one starting) without that delay.
    final port = portOf(await CoreLocator.dashboardListen(dataDir));
    if (port != null && await isFree(port)) {
      if (_running) throw const CoreUnavailable(CoreProblem.starting);
      if (!_failedFast) {
        if (!File(executable).existsSync()) {
          throw CoreUnavailable(CoreProblem.noCore, executable);
        }
        await _start();
        throw const CoreUnavailable(CoreProblem.starting);
      }
    }
    try {
      final api = CoreApi(await CoreLocator.locate(dataDir));
      try {
        await api.status();
        return api;
      } on Exception {
        api.close();
        rethrow;
      }
    } on CoreUnavailable {
      // Nothing answers: start the core below.
    } on CoreApiException {
      // Something else answers at the address: another program, or another
      // copy's core. The API moves; failing that, the user is asked.
      if (await _moveApi()) throw const CoreUnavailable(CoreProblem.starting);
      throw CoreUnavailable(
        CoreProblem.refused,
        await CoreLocator.dashboardListen(dataDir),
      );
    }
    if (_running) {
      // Started and still coming up; the next poll tries again.
      throw const CoreUnavailable(CoreProblem.starting);
    }
    if (_failedFast) {
      // It stopped right after starting: report why instead of looping.
      final error = _appError;
      // Its API's port is held, or Windows keeps it for itself (WSL and
      // Docker reserve ranges); nothing of ours answers there.
      if (error?.code == 'dashboard_port_in_use' ||
          (port != null && !await isFree(port))) {
        if (await _moveApi()) {
          throw const CoreUnavailable(CoreProblem.starting);
        }
      }
      if (error != null && error.code == 'dashboard_port_in_use') {
        throw CoreUnavailable(CoreProblem.portInUse, error.detail);
      }
      throw CoreUnavailable(
        CoreProblem.exited,
        error?.detail ?? _stderr.join(' '),
      );
    }
    if (!File(executable).existsSync()) {
      throw CoreUnavailable(CoreProblem.noCore, executable);
    }
    await _start();
    throw const CoreUnavailable(CoreProblem.starting);
  }

  /// Lets the next [connect] start the core at once, after the user fixed
  /// what stopped it (such as its port).
  void retryNow() => _failedFast = false;

  /// Times the API was moved this run: a few, so a lasting problem ends in
  /// the "can't reach the core" page instead of a loop.
  int _apiMoves = 0;

  /// Moves the core's API to a free port above its own, in config.json,
  /// for the core to start there. Unlike the proxy's port, which the user
  /// may have set up in other programs, only this app uses it, and it finds
  /// the port in that file. False when it was moved enough already.
  Future<bool> _moveApi() async {
    if (_apiMoves >= 3) return false;
    _apiMoves++;
    final listen = await CoreLocator.dashboardListen(dataDir);
    final port = await findFreePort(portOf(listen) ?? 10820);
    await CoreLocator.setDashboardListen(dataDir, withPort(listen, port));
    _failedFast = false;
    return true;
  }

  Future<void> _start() async {
    _stderr.clear();
    _appError = null;
    _startedAt = DateTime.now();
    // On Windows detachedWithStdio: no console window for the
    // console-subsystem core, and stdin stays a pipe that closes when this
    // process ends. Elsewhere a plain child, which the app reaps (a detached
    // one leaves a zombie on Linux).
    final p = await Process.start(
      executable,
      ['-app', '-data-dir', dataDir],
      mode: Platform.isWindows
          ? ProcessStartMode.detachedWithStdio
          : ProcessStartMode.normal,
    );
    _process = p;
    _running = true;
    p.stdout.drain<void>();
    p.stderr
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .listen(
          (line) {
            if (line.startsWith('app-error: ')) {
              final rest = line.substring('app-error: '.length);
              final space = rest.indexOf(' ');
              _appError = space < 0
                  ? (code: rest, detail: '')
                  : (
                      code: rest.substring(0, space),
                      detail: rest.substring(space + 1),
                    );
            }
            _stderr.add(line);
            if (_stderr.length > 5) _stderr.removeAt(0);
          },
          // The pipe closes when the core exits.
          onDone: () {
            if (_process != p) return;
            _running = false;
            final started = _startedAt;
            _failedFast =
                started != null &&
                DateTime.now().difference(started) <
                    const Duration(seconds: 15);
          },
        );
  }

  /// Ends the core if this app started it: asks it to exit, then closes its
  /// stdin, which ends it too.
  Future<void> stop(CoreApi? api) async {
    final p = _process;
    if (p == null || !_running) return;
    try {
      await api?.exitCore().timeout(const Duration(seconds: 3));
    } on Exception {
      // Closing stdin below stops it anyway.
    }
    try {
      await p.stdin.close().timeout(const Duration(seconds: 1));
    } on Exception {
      // Already gone.
    }
    // Give it a moment to restore the system proxy before the app exits.
    for (var i = 0; i < 30 && _running; i++) {
      await Future<void>.delayed(const Duration(milliseconds: 100));
    }
    _process = null;
    _running = false;
  }
}