rhg: Add support for automatic fallback to Python
authorSimon Sapin <simon.sapin@octobus.net>
Mon, 01 Mar 2021 20:36:06 +0100
changeset 46706 93e9f448273c
parent 46705 33f2d56acc73
child 46707 fb2368598281
rhg: Add support for automatic fallback to Python `rhg` is a command-line application that can do a small subset of what `hg` can. It is written entirely in Rust, which avoids the cost of starting a Python interpreter and importing many Python modules. In a script that runs many `hg` commands, this cost can add up. However making users decide when to use `rhg` instead of `hg` is not practical as we want the subset of supported functionality to grow over time. Instead we introduce "fallback" behavior where, when `rhg` encounters something (a sub-command, a repository format, …) that is not implemented in Rust-only, it does nothing but silently start a subprocess of Python-based `hg` running the same command. That way `rhg` becomes a drop-in replacement for `hg` that sometimes goes faster. Whether Python is used should be an implementation detail not apparent to users (other than through speed). A new `fallback` value is added to the previously introduced `rhg.on-unsupported` configuration key. When in this mode, the new `rhg.fallback-executable` config is determine what command to use to run a Python-based `hg`. The previous `rhg.on-unsupported = abort-silent` configuration was designed to let a wrapper script call `rhg` and then fall back to `hg` based on the exit code. This is still available, but having fallback behavior built-in in rhg might be easier for users instead of leaving that script "as an exercise for the reader". Using a subprocess like this is not idea, especially when `rhg` is to be installed in `$PATH` as `hg`, since the other `hg.py` executable needs to still be available… somewhere. Eventually this could be replaced by using PyOxidizer to a have a single executable that embeds a Python interpreter, but only starts it when needed. Differential Revision: https://phab.mercurial-scm.org/D10093
rust/rhg/src/main.rs
tests/test-rhg.t
--- a/rust/rhg/src/main.rs	Mon Mar 01 16:18:42 2021 +0100
+++ b/rust/rhg/src/main.rs	Mon Mar 01 20:36:06 2021 +0100
@@ -11,6 +11,7 @@
 use hg::utils::SliceExt;
 use std::ffi::OsString;
 use std::path::PathBuf;
+use std::process::Command;
 
 mod blackbox;
 mod error;
@@ -138,9 +139,43 @@
 
 fn exit(
     ui: &Ui,
-    on_unsupported: OnUnsupported,
+    mut on_unsupported: OnUnsupported,
     result: Result<(), CommandError>,
 ) -> ! {
+    if let (
+        OnUnsupported::Fallback { executable },
+        Err(CommandError::UnsupportedFeature { .. }),
+    ) = (&on_unsupported, &result)
+    {
+        let mut args = std::env::args_os();
+        let executable_path = get_path_from_bytes(&executable);
+        let this_executable = args.next().expect("exepcted argv[0] to exist");
+        if executable_path == &PathBuf::from(this_executable) {
+            // Avoid spawning infinitely many processes until resource
+            // exhaustion.
+            let _ = ui.write_stderr(&format_bytes!(
+                b"Blocking recursive fallback. The 'rhg.fallback-executable = {}' config \
+                points to `rhg` itself.\n",
+                executable
+            ));
+            on_unsupported = OnUnsupported::Abort
+        } else {
+            // `args` is now `argv[1..]` since we’ve already consumed `argv[0]`
+            let result = Command::new(executable_path).args(args).status();
+            match result {
+                Ok(status) => std::process::exit(
+                    status.code().unwrap_or(exitcode::ABORT),
+                ),
+                Err(error) => {
+                    let _ = ui.write_stderr(&format_bytes!(
+                        b"tried to fall back to a '{}' sub-process but got error {}\n",
+                        executable, format_bytes::Utf8(error)
+                    ));
+                    on_unsupported = OnUnsupported::Abort
+                }
+            }
+        }
+    }
     match &result {
         Ok(_) => {}
         Err(CommandError::Abort { message }) => {
@@ -160,6 +195,7 @@
                     ));
                 }
                 OnUnsupported::AbortSilent => {}
+                OnUnsupported::Fallback { .. } => unreachable!(),
             }
         }
     }
@@ -268,18 +304,32 @@
     Abort,
     /// Silently exit with code 252.
     AbortSilent,
+    /// Try running a Python implementation
+    Fallback { executable: Vec<u8> },
 }
 
 impl OnUnsupported {
+    const DEFAULT: Self = OnUnsupported::Abort;
+    const DEFAULT_FALLBACK_EXECUTABLE: &'static [u8] = b"hg";
+
     fn from_config(config: &Config) -> Self {
-        let default = OnUnsupported::Abort;
-        match config.get(b"rhg", b"on-unsupported") {
+        match config
+            .get(b"rhg", b"on-unsupported")
+            .map(|value| value.to_ascii_lowercase())
+            .as_deref()
+        {
             Some(b"abort") => OnUnsupported::Abort,
             Some(b"abort-silent") => OnUnsupported::AbortSilent,
-            None => default,
+            Some(b"fallback") => OnUnsupported::Fallback {
+                executable: config
+                    .get(b"rhg", b"fallback-executable")
+                    .unwrap_or(Self::DEFAULT_FALLBACK_EXECUTABLE)
+                    .to_owned(),
+            },
+            None => Self::DEFAULT,
             Some(_) => {
                 // TODO: warn about unknown config value
-                default
+                Self::DEFAULT
             }
         }
     }
--- a/tests/test-rhg.t	Mon Mar 01 16:18:42 2021 +0100
+++ b/tests/test-rhg.t	Mon Mar 01 20:36:06 2021 +0100
@@ -1,9 +1,10 @@
 #require rust
 
 Define an rhg function that will only run if rhg exists
+  $ RHG="$RUNTESTDIR/../rust/target/release/rhg"
   $ rhg() {
-  > if [ -f "$RUNTESTDIR/../rust/target/release/rhg" ]; then
-  >   "$RUNTESTDIR/../rust/target/release/rhg" "$@"
+  > if [ -f "$RHG" ]; then
+  >   "$RHG" "$@"
   > else
   >   echo "skipped: Cannot find rhg. Try to run cargo build in rust/rhg."
   >   exit 80
@@ -151,6 +152,27 @@
   $ rhg cat -r 1 copy_of_original
   original content
 
+Fallback to Python
+  $ rhg cat original
+  unsupported feature: `rhg cat` without `--rev` / `-r`
+  [252]
+  $ FALLBACK="--config rhg.on-unsupported=fallback"
+  $ rhg cat original $FALLBACK
+  original content
+
+  $ rhg cat original $FALLBACK --config rhg.fallback-executable=false
+  [1]
+
+  $ rhg cat original $FALLBACK --config rhg.fallback-executable=hg-non-existent
+  tried to fall back to a 'hg-non-existent' sub-process but got error $ENOENT$
+  unsupported feature: `rhg cat` without `--rev` / `-r`
+  [252]
+
+  $ rhg cat original $FALLBACK --config rhg.fallback-executable="$RHG"
+  Blocking recursive fallback. The 'rhg.fallback-executable = */rust/target/release/rhg' config points to `rhg` itself. (glob)
+  unsupported feature: `rhg cat` without `--rev` / `-r`
+  [252]
+
 Requirements
   $ rhg debugrequirements
   dotencode