diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml
new file mode 100644
index 0000000..dfcf6cd
--- /dev/null
+++ b/.github/workflows/process.yml
@@ -0,0 +1,38 @@
+name: Process Package
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  workflow_dispatch:
+
+jobs:
+  correctness:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
+      - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603
+        with:
+          sdk: dev
+      - name: Install dependencies
+        run: dart pub upgrade
+      - name: Verify formatting
+        run: dart format --output=none --set-exit-if-changed .
+      - name: Analyze project source
+        run: dart analyze --fatal-infos
+  test:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+        sdk: [stable, beta, dev]
+    steps:
+      - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
+      - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603
+        with:
+          sdk: ${{ matrix.sdk }}
+      - name: Install dependencies
+        run: dart pub upgrade
+      - name: Run Tests
+        run: dart test
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5722c5b..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-language: dart
-sudo: false
-dart:
-  - dev
-install:
-  - gem install coveralls-lcov
-before_script:
-  - ./dev/setup.sh
-script:
-  - ./dev/travis.sh
-after_success:
-  - (coveralls-lcov coverage/lcov.info)
-cache:
-  directories:
-    - $HOME/.pub-cache
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04be959..ff6cd05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,47 @@
+#### 4.2.4
+
+* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable again,
+  now that the upstream SDK issue has been fixed.
+
+#### 4.2.3
+
+* Rollback to version 4.2.1 (https://github.com/google/process.dart/issues/64)
+
+#### 4.2.2
+
+* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable.
+
+#### 4.2.1
+
+* Added custom exception types `ProcessPackageException` and
+  `ProcessPackageExecutableNotFoundException` to provide extra
+  information from exception conditions.
+
+#### 4.2.0
+
+* Fix the signature of `ProcessManager.canRun` to be consistent with
+  `LocalProcessManager`.
+
+#### 4.1.1
+
+* Fixed `getExecutablePath()` to only return path items that are
+  executable and readable to the user.
+
+#### 4.1.0
+
+* Fix the signatures of `ProcessManager.run`, `.runSync`, and `.start` to be
+  consistent with `LocalProcessManager`'s.
+* Added more details to the `ArgumentError` thrown when a command cannot be resolved
+  to an executable.
+
+#### 4.0.0
+
+* First stable null safe release.
+
+#### 4.0.0-nullsafety.4
+
+* Update supported SDK range.
+
 #### 4.0.0-nullsafety.3
 
 * Update supported SDK range.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 2c72e4b..8fbd2e4 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,67 +1,6 @@
+include: package:lints/recommended.yaml
+
 analyzer:
-  enable-experiment:
-    - non-nullable
-  strong-mode:
-    implicit-dynamic: false
-    implicit-casts: false
   errors:
-    missing_required_param: warning
-    missing_return: warning
     # Allow having TODOs in the code
     todo: ignore
-
-linter:
-  rules:
-    # these rules are documented on and in the same order as
-    # the Dart Lint rules page to make maintenance easier
-    # http://dart-lang.github.io/linter/lints/
-
-    # === error rules ===
-    - avoid_empty_else
-    - comment_references
-    - cancel_subscriptions
-    - close_sinks
-    - control_flow_in_finally
-    - empty_statements
-    - hash_and_equals
-    - invariant_booleans
-    - iterable_contains_unrelated_type
-    - list_remove_unrelated_type
-    - literal_only_boolean_expressions
-    - test_types_in_equals
-    - throw_in_finally
-    - unrelated_type_equality_checks
-    - valid_regexps
-
-    # === style rules ===
-    - always_declare_return_types
-    - always_specify_types
-    - annotate_overrides
-    - avoid_init_to_null
-    - avoid_return_types_on_setters
-    - await_only_futures
-    - camel_case_types
-    - constant_identifier_names
-    - empty_constructor_bodies
-    - implementation_imports
-    - library_names
-    - library_prefixes
-    - non_constant_identifier_names
-    - one_member_abstracts
-    - only_throw_errors
-    - overridden_fields
-    - package_api_docs
-    - package_prefixed_library_names
-    - prefer_is_not_empty
-    - public_member_api_docs
-    - slash_for_doc_comments
-    - sort_constructors_first
-    - sort_unnamed_constructors_first
-    - type_annotate_public_apis
-    - type_init_formals
-    - unawaited_futures
-    - unnecessary_brace_in_string_interps
-    - unnecessary_getters_setters
-
-    # === pub rules ===
-    - package_names
diff --git a/dev/setup.sh b/dev/setup.sh
deleted file mode 100755
index 99ec284..0000000
--- a/dev/setup.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-# Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
-# for details. All rights reserved. Use of this source code is governed by a
-# BSD-style license that can be found in the LICENSE file.
-
-pub upgrade
diff --git a/dev/travis.sh b/dev/travis.sh
deleted file mode 100755
index 866b361..0000000
--- a/dev/travis.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-# Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
-# for details. All rights reserved. Use of this source code is governed by a
-# BSD-style license that can be found in the LICENSE file.
-
-# Make sure dartfmt is run on everything
-echo "Checking dartfmt..."
-needs_dartfmt="$(dartfmt -n lib test dev)"
-if [[ -n "$needs_dartfmt" ]]; then
-  echo "FAILED"
-  echo "$needs_dartfmt"
-  exit 1
-fi
-echo "PASSED"
-
-# Make sure we pass the analyzer
-echo "Checking dartanalyzer..."
-fails_analyzer="$(find lib test dev -name "*.dart" | xargs dartanalyzer --options .analysis_options)"
-if [[ "$fails_analyzer" == *"[error]"* ]]; then
-  echo "FAILED"
-  echo "$fails_analyzer"
-  exit 1
-fi
-echo "PASSED"
-
-# Fast fail the script on failures.
-set -e
-
-# Run the tests.
-pub run test
diff --git a/lib/process.dart b/lib/process.dart
index dfeb1d0..af513a0 100644
--- a/lib/process.dart
+++ b/lib/process.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+export 'src/interface/exceptions.dart';
 export 'src/interface/local_process_manager.dart';
 export 'src/interface/process_manager.dart';
 export 'src/interface/process_wrapper.dart';
diff --git a/lib/src/interface/common.dart b/lib/src/interface/common.dart
index a3aed86..25d52f5 100644
--- a/lib/src/interface/common.dart
+++ b/lib/src/interface/common.dart
@@ -7,6 +7,8 @@
 import 'package:path/path.dart' show Context;
 import 'package:platform/platform.dart';
 
+import 'exceptions.dart';
+
 const Map<String, String> _osToPathStyle = <String, String>{
   'linux': 'posix',
   'macos': 'posix',
@@ -16,7 +18,7 @@
   'windows': 'windows',
 };
 
-/// Sanatizes the executable path on Windows.
+/// Sanitizes the executable path on Windows.
 /// https://github.com/dart-lang/sdk/issues/37751
 String sanitizeExecutablePath(String executable,
     {Platform platform = const LocalPlatform()}) {
@@ -34,10 +36,10 @@
   return executable;
 }
 
-/// Searches the `PATH` for the executable that [command] is supposed to launch.
+/// Searches the `PATH` for the executable that [executable] is supposed to launch.
 ///
 /// This first builds a list of candidate paths where the executable may reside.
-/// If [command] is already an absolute path, then the `PATH` environment
+/// If [executable] is already an absolute path, then the `PATH` environment
 /// variable will not be consulted, and the specified absolute path will be the
 /// only candidate that is considered.
 ///
@@ -49,13 +51,13 @@
 ///
 /// If [platform] is not specified, it will default to the current platform.
 String? getExecutablePath(
-  String command,
+  String executable,
   String? workingDirectory, {
   Platform platform = const LocalPlatform(),
   FileSystem fs = const LocalFileSystem(),
+  bool throwOnFailure = false,
 }) {
   assert(_osToPathStyle[platform.operatingSystem] == fs.path.style.name);
-
   try {
     workingDirectory ??= fs.currentDirectory.path;
   } on FileSystemException {
@@ -71,24 +73,66 @@
   String pathSeparator = platform.isWindows ? ';' : ':';
 
   List<String> extensions = <String>[];
-  if (platform.isWindows && context.extension(command).isEmpty) {
+  if (platform.isWindows && context.extension(executable).isEmpty) {
     extensions = platform.environment['PATHEXT']!.split(pathSeparator);
   }
 
   List<String> candidates = <String>[];
-  if (command.contains(context.separator)) {
-    candidates = _getCandidatePaths(
-        command, <String>[workingDirectory], extensions, context);
+  List<String> searchPath;
+  if (executable.contains(context.separator)) {
+    // Deal with commands that specify a relative or absolute path differently.
+    searchPath = <String>[workingDirectory];
   } else {
-    List<String> searchPath =
-        platform.environment['PATH']!.split(pathSeparator);
-    candidates = _getCandidatePaths(command, searchPath, extensions, context);
+    searchPath = platform.environment['PATH']!.split(pathSeparator);
   }
+  candidates = _getCandidatePaths(executable, searchPath, extensions, context);
+  final List<String> foundCandidates = <String>[];
   for (String path in candidates) {
-    if (fs.file(path).existsSync()) {
+    final File candidate = fs.file(path);
+    FileStat stat = candidate.statSync();
+    // Only return files or links that exist.
+    if (stat.type == FileSystemEntityType.notFound ||
+        stat.type == FileSystemEntityType.directory) {
+      continue;
+    }
+
+    // File exists, but we don't know if it's readable/executable yet.
+    foundCandidates.add(candidate.path);
+
+    const int isExecutable = 0x40;
+    const int isReadable = 0x100;
+    const int isExecutableAndReadable = isExecutable | isReadable;
+    // Should only return files or links that are readable and executable by the
+    // user.
+
+    // On Windows it's not actually possible to only return files that are
+    // readable, since Dart reports files that have had read permission removed
+    // as being readable, but not checking for it is the same as checking for it
+    // and finding it readable, so we use the same check here on all platforms,
+    // so that if Dart ever gets fixed, it'll just work.
+    if (stat.mode & isExecutableAndReadable == isExecutableAndReadable) {
       return path;
     }
   }
+  if (throwOnFailure) {
+    if (foundCandidates.isNotEmpty) {
+      throw ProcessPackageExecutableNotFoundException(
+        executable,
+        message:
+            'Found candidates, but lacked sufficient permissions to execute "$executable".',
+        workingDirectory: workingDirectory,
+        candidates: foundCandidates,
+        searchPath: searchPath,
+      );
+    } else {
+      throw ProcessPackageExecutableNotFoundException(
+        executable,
+        message: 'Failed to find "$executable" in the search path.',
+        workingDirectory: workingDirectory,
+        searchPath: searchPath,
+      );
+    }
+  }
   return null;
 }
 
diff --git a/lib/src/interface/exceptions.dart b/lib/src/interface/exceptions.dart
new file mode 100644
index 0000000..ee407ca
--- /dev/null
+++ b/lib/src/interface/exceptions.dart
@@ -0,0 +1,106 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io' show ProcessException;
+
+/// A specialized exception class for this package, so that it can throw
+/// customized exceptions with more information.
+class ProcessPackageException extends ProcessException {
+  /// Create a const ProcessPackageException.
+  ///
+  /// The [executable] should be the name of the executable to be run.
+  ///
+  /// The optional [workingDirectory] is the directory where the command
+  /// execution is attempted.
+  ///
+  /// The optional [arguments] is a list of the arguments to given to the
+  /// executable, already separated.
+  ///
+  /// The optional [message] is an additional message to be included in the
+  /// exception string when printed.
+  ///
+  /// The optional [errorCode] is the error code received when the executable
+  /// was run. Zero means it ran successfully, or that no error code was
+  /// available.
+  ///
+  /// See [ProcessException] for more information.
+  const ProcessPackageException(
+    String executable, {
+    List<String> arguments = const <String>[],
+    String message = "",
+    int errorCode = 0,
+    this.workingDirectory,
+  }) : super(executable, arguments, message, errorCode);
+
+  /// Creates a [ProcessPackageException] from a [ProcessException].
+  factory ProcessPackageException.fromProcessException(
+    ProcessException exception, {
+    String? workingDirectory,
+  }) {
+    return ProcessPackageException(
+      exception.executable,
+      arguments: exception.arguments,
+      message: exception.message,
+      errorCode: exception.errorCode,
+      workingDirectory: workingDirectory,
+    );
+  }
+
+  /// The optional working directory that the command was being executed in.
+  final String? workingDirectory;
+
+  // Don't implement a toString() for this exception, since code may be
+  // depending upon the format of ProcessException.toString().
+}
+
+/// An exception for when an executable is not found that was expected to be found.
+class ProcessPackageExecutableNotFoundException
+    extends ProcessPackageException {
+  /// Creates a const ProcessPackageExecutableNotFoundException
+  ///
+  /// The optional [candidates] are the files matching the expected executable
+  /// on the [searchPath].
+  ///
+  /// The optional [searchPath] is the list of directories searched for the
+  /// expected executable.
+  ///
+  /// See [ProcessPackageException] for more information.
+  const ProcessPackageExecutableNotFoundException(
+    String executable, {
+    List<String> arguments = const <String>[],
+    String message = "",
+    int errorCode = 0,
+    String? workingDirectory,
+    this.candidates = const <String>[],
+    this.searchPath = const <String>[],
+  }) : super(
+          executable,
+          arguments: arguments,
+          message: message,
+          errorCode: errorCode,
+          workingDirectory: workingDirectory,
+        );
+
+  /// The list of non-viable executable candidates found.
+  final List<String> candidates;
+
+  /// The search path used to find candidates.
+  final List<String> searchPath;
+
+  @override
+  String toString() {
+    StringBuffer buffer = StringBuffer('$runtimeType: $message\n');
+    // Don't add an extra space if there are no arguments.
+    final String args = arguments.isNotEmpty ? ' ${arguments.join(' ')}' : '';
+    buffer.writeln('  Command: $executable$args');
+    if (workingDirectory != null && workingDirectory!.isNotEmpty) {
+      buffer.writeln('  Working Directory: $workingDirectory');
+    }
+    if (candidates.isNotEmpty) {
+      buffer.writeln('  Candidates:\n    ${candidates.join('\n    ')}');
+    }
+    buffer.writeln('  Search Path:\n    ${searchPath.join('\n    ')}');
+    return buffer.toString();
+  }
+}
diff --git a/lib/src/interface/local_process_manager.dart b/lib/src/interface/local_process_manager.dart
index d5133bb..fc260b2 100644
--- a/lib/src/interface/local_process_manager.dart
+++ b/lib/src/interface/local_process_manager.dart
@@ -9,9 +9,11 @@
         ProcessResult,
         ProcessSignal,
         ProcessStartMode,
+        ProcessException,
         systemEncoding;
 
 import 'common.dart';
+import 'exceptions.dart';
 import 'process_manager.dart';
 
 /// Local implementation of the `ProcessManager` interface.
@@ -29,83 +31,99 @@
 
   @override
   Future<Process> start(
-    covariant List<Object> command, {
+    List<Object> command, {
     String? workingDirectory,
     Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
     ProcessStartMode mode = ProcessStartMode.normal,
   }) {
-    return Process.start(
-      sanitizeExecutablePath(_getExecutable(
-        command,
-        workingDirectory,
-        runInShell,
-      )),
-      _getArguments(command),
-      workingDirectory: workingDirectory,
-      environment: environment,
-      includeParentEnvironment: includeParentEnvironment,
-      runInShell: runInShell,
-      mode: mode,
-    );
+    try {
+      return Process.start(
+        sanitizeExecutablePath(_getExecutable(
+          command,
+          workingDirectory,
+          runInShell,
+        )),
+        _getArguments(command),
+        workingDirectory: workingDirectory,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment,
+        runInShell: runInShell,
+        mode: mode,
+      );
+    } on ProcessException catch (exception) {
+      throw ProcessPackageException.fromProcessException(exception,
+          workingDirectory: workingDirectory);
+    }
   }
 
   @override
   Future<ProcessResult> run(
-    covariant List<Object> command, {
+    List<Object> command, {
     String? workingDirectory,
     Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
-    Encoding stdoutEncoding = systemEncoding,
-    Encoding stderrEncoding = systemEncoding,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
   }) {
-    return Process.run(
-      sanitizeExecutablePath(_getExecutable(
-        command,
-        workingDirectory,
-        runInShell,
-      )),
-      _getArguments(command),
-      workingDirectory: workingDirectory,
-      environment: environment,
-      includeParentEnvironment: includeParentEnvironment,
-      runInShell: runInShell,
-      stdoutEncoding: stdoutEncoding,
-      stderrEncoding: stderrEncoding,
-    );
+    try {
+      return Process.run(
+        sanitizeExecutablePath(_getExecutable(
+          command,
+          workingDirectory,
+          runInShell,
+        )),
+        _getArguments(command),
+        workingDirectory: workingDirectory,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment,
+        runInShell: runInShell,
+        stdoutEncoding: stdoutEncoding,
+        stderrEncoding: stderrEncoding,
+      );
+    } on ProcessException catch (exception) {
+      throw ProcessPackageException.fromProcessException(exception,
+          workingDirectory: workingDirectory);
+    }
   }
 
   @override
   ProcessResult runSync(
-    covariant List<Object> command, {
+    List<Object> command, {
     String? workingDirectory,
     Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
-    Encoding stdoutEncoding = systemEncoding,
-    Encoding stderrEncoding = systemEncoding,
+    Encoding? stdoutEncoding = systemEncoding,
+    Encoding? stderrEncoding = systemEncoding,
   }) {
-    return Process.runSync(
-      sanitizeExecutablePath(_getExecutable(
-        command,
-        workingDirectory,
-        runInShell,
-      )),
-      _getArguments(command),
-      workingDirectory: workingDirectory,
-      environment: environment,
-      includeParentEnvironment: includeParentEnvironment,
-      runInShell: runInShell,
-      stdoutEncoding: stdoutEncoding,
-      stderrEncoding: stderrEncoding,
-    );
+    try {
+      return Process.runSync(
+        sanitizeExecutablePath(_getExecutable(
+          command,
+          workingDirectory,
+          runInShell,
+        )),
+        _getArguments(command),
+        workingDirectory: workingDirectory,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment,
+        runInShell: runInShell,
+        stdoutEncoding: stdoutEncoding,
+        stderrEncoding: stderrEncoding,
+      );
+    } on ProcessException catch (exception) {
+      throw ProcessPackageException.fromProcessException(exception,
+          workingDirectory: workingDirectory);
+    }
   }
 
   @override
   bool canRun(covariant String executable, {String? workingDirectory}) =>
-      getExecutablePath(executable, workingDirectory) != null;
+      getExecutablePath(executable, workingDirectory, throwOnFailure: false) !=
+      null;
 
   @override
   bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) {
@@ -119,14 +137,14 @@
   if (runInShell) {
     return commandName;
   }
-  String? exe = getExecutablePath(commandName, workingDirectory);
-  if (exe == null) {
-    throw ArgumentError('Cannot find executable for $commandName.');
-  }
-  return exe;
+  return getExecutablePath(
+    commandName,
+    workingDirectory,
+    throwOnFailure: true,
+  )!;
 }
 
-List<String> _getArguments(List<dynamic> command) =>
+List<String> _getArguments(List<Object> command) =>
     // Adding a specific type to map in order to workaround dart issue
     // https://github.com/dart-lang/sdk/issues/32414
     command
diff --git a/lib/src/interface/process_manager.dart b/lib/src/interface/process_manager.dart
index 5372742..d99a874 100644
--- a/lib/src/interface/process_manager.dart
+++ b/lib/src/interface/process_manager.dart
@@ -83,9 +83,9 @@
   ///
   /// The default value for `mode` is `ProcessStartMode.normal`.
   Future<Process> start(
-    List<dynamic> command, {
-    String workingDirectory,
-    Map<String, String> environment,
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
     ProcessStartMode mode = ProcessStartMode.normal,
@@ -136,13 +136,14 @@
   ///       stderr.write(result.stderr);
   ///     });
   Future<ProcessResult> run(
-    List<dynamic> command, {
-    String workingDirectory,
-    Map<String, String> environment,
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
-    Encoding stdoutEncoding = systemEncoding,
-    Encoding stderrEncoding = systemEncoding,
+    // TODO(#64): Remove the `covariant` keyword.
+    covariant Encoding? stdoutEncoding = systemEncoding,
+    covariant Encoding? stderrEncoding = systemEncoding,
   });
 
   /// Starts a process and runs it to completion. This is a synchronous
@@ -153,17 +154,18 @@
   /// Returns a `ProcessResult` with the result of running the process,
   /// i.e., exit code, standard out and standard in.
   ProcessResult runSync(
-    List<dynamic> command, {
-    String workingDirectory,
-    Map<String, String> environment,
+    List<Object> command, {
+    String? workingDirectory,
+    Map<String, String>? environment,
     bool includeParentEnvironment = true,
     bool runInShell = false,
-    Encoding stdoutEncoding = systemEncoding,
-    Encoding stderrEncoding = systemEncoding,
+    // TODO(#64): Remove the `covariant` keyword.
+    covariant Encoding? stdoutEncoding = systemEncoding,
+    covariant Encoding? stderrEncoding = systemEncoding,
   });
 
   /// Returns `true` if the [executable] exists and if it can be executed.
-  bool canRun(dynamic executable, {String workingDirectory});
+  bool canRun(dynamic executable, {String? workingDirectory});
 
   /// Kills the process with id [pid].
   ///
diff --git a/pubspec.yaml b/pubspec.yaml
index 658feb5..ec925cc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,15 +1,16 @@
 name: process
-version: 4.0.0-nullsafety.4
+version: 4.2.4
 description: A pluggable, mockable process invocation abstraction for Dart.
 homepage: https://github.com/google/process.dart
 
 environment:
-  sdk: '>=2.12.0-0 <3.0.0'
+  sdk: '>=2.14.0-0 <3.0.0'
 
 dependencies:
-  file: '^6.0.0-nullsafety.4'
-  path: ^1.8.0-nullsafety.3
-  platform: '^3.0.0-nullsafety.4'
+  file: '^6.0.0'
+  path: ^1.8.0
+  platform: '^3.0.0'
 
 dev_dependencies:
-  test: ^1.16.0-nullsafety.8
+  lints: ^1.0.1
+  test: ^1.16.8
diff --git a/test/src/interface/common_test.dart b/test/src/interface/common_test.dart
index 6a3e2df..4f53269 100644
--- a/test/src/interface/common_test.dart
+++ b/test/src/interface/common_test.dart
@@ -2,9 +2,12 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'dart:io' as io;
+import 'package:file/local.dart';
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
 import 'package:platform/platform.dart';
+import 'package:process/process.dart';
 import 'package:process/src/interface/common.dart';
 import 'package:test/test.dart';
 
@@ -24,8 +27,9 @@
     }
 
     tearDown(() {
-      <Directory>[workingDir, dir1, dir2, dir3]
-          .forEach((Directory d) => d.deleteSync(recursive: true));
+      for (var directory in <Directory>[workingDir, dir1, dir2, dir3]) {
+        directory.deleteSync(recursive: true);
+      }
     });
 
     group('on windows', () {
@@ -192,26 +196,56 @@
         expect(executablePath, isNull);
       });
 
+      test('not found with throwOnFailure throws exception with match state',
+          () {
+        String command = 'foo.exe';
+        io.ProcessException error;
+        try {
+          getExecutablePath(
+            command,
+            workingDir.path,
+            platform: platform,
+            fs: fs,
+            throwOnFailure: true,
+          );
+          fail('Expected to throw');
+        } on io.ProcessException catch (err) {
+          error = err;
+        }
+
+        expect(error, isA<ProcessPackageExecutableNotFoundException>());
+        ProcessPackageExecutableNotFoundException notFoundException =
+            error as ProcessPackageExecutableNotFoundException;
+        expect(notFoundException.candidates, isEmpty);
+        expect(notFoundException.workingDirectory, equals(workingDir.path));
+        expect(
+            error.toString(),
+            contains('  Working Directory: C:\\.tmp_rand0\\work_dir_rand0\n'
+                '  Search Path:\n'
+                '    C:\\.tmp_rand0\\dir1_rand0\n'
+                '    C:\\.tmp_rand0\\dir2_rand0\n'));
+      });
+
       test('when path has spaces', () {
         expect(
-            sanitizeExecutablePath('Program Files\\bla.exe',
+            sanitizeExecutablePath(r'Program Files\bla.exe',
                 platform: platform),
-            '"Program Files\\bla.exe"');
+            r'"Program Files\bla.exe"');
         expect(
-            sanitizeExecutablePath('ProgramFiles\\bla.exe', platform: platform),
-            'ProgramFiles\\bla.exe');
+            sanitizeExecutablePath(r'ProgramFiles\bla.exe', platform: platform),
+            r'ProgramFiles\bla.exe');
         expect(
-            sanitizeExecutablePath('"Program Files\\bla.exe"',
+            sanitizeExecutablePath(r'"Program Files\bla.exe"',
                 platform: platform),
-            '"Program Files\\bla.exe"');
+            r'"Program Files\bla.exe"');
         expect(
-            sanitizeExecutablePath('\"Program Files\\bla.exe\"',
+            sanitizeExecutablePath(r'"Program Files\bla.exe"',
                 platform: platform),
-            '\"Program Files\\bla.exe\"');
+            r'"Program Files\bla.exe"');
         expect(
-            sanitizeExecutablePath('C:\\\"Program Files\"\\bla.exe',
+            sanitizeExecutablePath(r'C:\"Program Files"\bla.exe',
                 platform: platform),
-            'C:\\\"Program Files\"\\bla.exe');
+            r'C:\"Program Files"\bla.exe');
       });
 
       test('with absolute path when currentDirectory getter throws', () {
@@ -298,6 +332,36 @@
         expect(executablePath, isNull);
       });
 
+      test('not found with throwOnFailure throws exception with match state',
+          () {
+        String command = 'foo';
+        io.ProcessException error;
+        try {
+          getExecutablePath(
+            command,
+            workingDir.path,
+            platform: platform,
+            fs: fs,
+            throwOnFailure: true,
+          );
+          fail('Expected to throw');
+        } on io.ProcessException catch (err) {
+          error = err;
+        }
+
+        expect(error, isA<ProcessPackageExecutableNotFoundException>());
+        ProcessPackageExecutableNotFoundException notFoundException =
+            error as ProcessPackageExecutableNotFoundException;
+        expect(notFoundException.candidates, isEmpty);
+        expect(notFoundException.workingDirectory, equals(workingDir.path));
+        expect(
+            error.toString(),
+            contains('  Working Directory: /.tmp_rand0/work_dir_rand0\n'
+                '  Search Path:\n'
+                '    /.tmp_rand0/dir1_rand0\n'
+                '    /.tmp_rand0/dir2_rand0\n'));
+      });
+
       test('when path has spaces', () {
         expect(
             sanitizeExecutablePath('/usr/local/bin/foo bar',
@@ -306,6 +370,196 @@
       });
     });
   });
+  group('Real Filesystem', () {
+    // These tests don't use the memory filesystem because Dart can't modify file
+    // executable permissions, so we have to create them with actual commands.
+
+    late Platform platform;
+    late Directory tmpDir;
+    late Directory pathDir1;
+    late Directory pathDir2;
+    late Directory pathDir3;
+    late Directory pathDir4;
+    late Directory pathDir5;
+    late File command1;
+    late File command2;
+    late File command3;
+    late File command4;
+    late File command5;
+    const Platform localPlatform = LocalPlatform();
+    late FileSystem fs;
+
+    setUp(() {
+      fs = LocalFileSystem();
+      tmpDir = fs.systemTempDirectory.createTempSync();
+      pathDir1 = tmpDir.childDirectory('path1')..createSync();
+      pathDir2 = tmpDir.childDirectory('path2')..createSync();
+      pathDir3 = tmpDir.childDirectory('path3')..createSync();
+      pathDir4 = tmpDir.childDirectory('path4')..createSync();
+      pathDir5 = tmpDir.childDirectory('path5')..createSync();
+      command1 = pathDir1.childFile('command')..createSync();
+      command2 = pathDir2.childFile('command')..createSync();
+      command3 = pathDir3.childFile('command')..createSync();
+      command4 = pathDir4.childFile('command')..createSync();
+      command5 = pathDir5.childFile('command')..createSync();
+      platform = FakePlatform(
+        operatingSystem: localPlatform.operatingSystem,
+        environment: <String, String>{
+          'PATH': <Directory>[
+            pathDir1,
+            pathDir2,
+            pathDir3,
+            pathDir4,
+            pathDir5,
+          ].map<String>((Directory dir) => dir.absolute.path).join(':'),
+        },
+      );
+    });
+
+    tearDown(() {
+      tmpDir.deleteSync(recursive: true);
+    });
+
+    test('Only returns executables in PATH', () {
+      if (localPlatform.isWindows) {
+        // Windows doesn't check for executable-ness, and we can't run 'chmod'
+        // on Windows anyhow.
+        return;
+      }
+
+      // Make the second command in the path executable, but not the first.
+      // No executable permissions
+      io.Process.runSync("chmod", <String>["0644", "--", command1.path]);
+      // Only group executable permissions
+      io.Process.runSync("chmod", <String>["0645", "--", command2.path]);
+      // Only other executable permissions
+      io.Process.runSync("chmod", <String>["0654", "--", command3.path]);
+      // All executable permissions, but not readable
+      io.Process.runSync("chmod", <String>["0311", "--", command4.path]);
+      // All executable permissions
+      io.Process.runSync("chmod", <String>["0755", "--", command5.path]);
+
+      String? executablePath = getExecutablePath(
+        'command',
+        tmpDir.path,
+        platform: platform,
+        fs: fs,
+      );
+
+      // Make sure that the path returned is for the last command, since that
+      // one comes last in the PATH, but is the only one executable by the
+      // user.
+      _expectSamePath(executablePath, command5.absolute.path);
+    });
+
+    test(
+        'Test that finding non-executable paths throws with proper information',
+        () {
+      if (localPlatform.isWindows) {
+        // Windows doesn't check for executable-ness, and we can't run 'chmod'
+        // on Windows anyhow.
+        return;
+      }
+
+      // Make the second command in the path executable, but not the first.
+      // No executable permissions
+      io.Process.runSync("chmod", <String>["0644", "--", command1.path]);
+      // Only group executable permissions
+      io.Process.runSync("chmod", <String>["0645", "--", command2.path]);
+      // Only other executable permissions
+      io.Process.runSync("chmod", <String>["0654", "--", command3.path]);
+      // All executable permissions, but not readable
+      io.Process.runSync("chmod", <String>["0311", "--", command4.path]);
+
+      io.ProcessException error;
+      try {
+        getExecutablePath(
+          'command',
+          tmpDir.path,
+          platform: platform,
+          fs: fs,
+          throwOnFailure: true,
+        );
+        fail('Expected to throw');
+      } on io.ProcessException catch (err) {
+        error = err;
+      }
+
+      expect(error, isA<ProcessPackageExecutableNotFoundException>());
+      ProcessPackageExecutableNotFoundException notFoundException =
+          error as ProcessPackageExecutableNotFoundException;
+      expect(
+          notFoundException.candidates,
+          equals(<String>[
+            '${tmpDir.path}/path1/command',
+            '${tmpDir.path}/path2/command',
+            '${tmpDir.path}/path3/command',
+            '${tmpDir.path}/path4/command',
+            '${tmpDir.path}/path5/command',
+          ]));
+      expect(
+        error.toString(),
+        equals(
+          'ProcessPackageExecutableNotFoundException: Found candidates, but lacked sufficient permissions to execute "command".\n'
+          '  Command: command\n'
+          '  Working Directory: ${tmpDir.path}\n'
+          '  Candidates:\n'
+          '    ${tmpDir.path}/path1/command\n'
+          '    ${tmpDir.path}/path2/command\n'
+          '    ${tmpDir.path}/path3/command\n'
+          '    ${tmpDir.path}/path4/command\n'
+          '    ${tmpDir.path}/path5/command\n'
+          '  Search Path:\n'
+          '    ${tmpDir.path}/path1\n'
+          '    ${tmpDir.path}/path2\n'
+          '    ${tmpDir.path}/path3\n'
+          '    ${tmpDir.path}/path4\n'
+          '    ${tmpDir.path}/path5\n',
+        ),
+      );
+    });
+
+    test('Test that finding no executable paths throws with proper information',
+        () {
+      if (localPlatform.isWindows) {
+        // Windows doesn't check for executable-ness, and we can't run 'chmod'
+        // on Windows anyhow.
+        return;
+      }
+
+      io.ProcessException error;
+      try {
+        getExecutablePath(
+          'non-existent-command',
+          tmpDir.path,
+          platform: platform,
+          fs: fs,
+          throwOnFailure: true,
+        );
+        fail('Expected to throw');
+      } on io.ProcessException catch (err) {
+        error = err;
+      }
+
+      expect(error, isA<ProcessPackageExecutableNotFoundException>());
+      ProcessPackageExecutableNotFoundException notFoundException =
+          error as ProcessPackageExecutableNotFoundException;
+      expect(notFoundException.candidates, isEmpty);
+      expect(
+        error.toString(),
+        equals(
+            'ProcessPackageExecutableNotFoundException: Failed to find "non-existent-command" in the search path.\n'
+            '  Command: non-existent-command\n'
+            '  Working Directory: ${tmpDir.path}\n'
+            '  Search Path:\n'
+            '    ${tmpDir.path}/path1\n'
+            '    ${tmpDir.path}/path2\n'
+            '    ${tmpDir.path}/path3\n'
+            '    ${tmpDir.path}/path4\n'
+            '    ${tmpDir.path}/path5\n'),
+      );
+    });
+  });
 }
 
 void _expectSamePath(String? actual, String? expected) {