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
--- 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