diff --git a/src/bin/julialauncher.rs b/src/bin/julialauncher.rs index d823252b..79c6de4d 100644 --- a/src/bin/julialauncher.rs +++ b/src/bin/julialauncher.rs @@ -98,6 +98,14 @@ fn run_versiondb_update( Ok(()) } +fn run_startup_updates( + config_file: &juliaup::config_file::JuliaupReadonlyConfigFile, +) -> Result<()> { + run_versiondb_update(config_file).with_context(|| "Failed to run version db update")?; + run_selfupdate(config_file).with_context(|| "Failed to run selfupdate.")?; + Ok(()) +} + #[cfg(feature = "selfupdate")] fn run_selfupdate(config_file: &juliaup::config_file::JuliaupReadonlyConfigFile) -> Result<()> { use chrono::Utc; @@ -135,25 +143,31 @@ fn run_selfupdate(_config_file: &juliaup::config_file::JuliaupReadonlyConfigFile } fn is_interactive() -> bool { - // First check if we have TTY access - this is a prerequisite for interactivity - if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { - return false; - } - - // Even with TTY available, check if Julia is being invoked in a non-interactive way let args: Vec = std::env::args().collect(); // Skip the first argument (program name) and any channel specification (+channel) let mut julia_args = args.iter().skip(1); - // Skip channel specification if present if let Some(first_arg) = julia_args.clone().next() { if first_arg.starts_with('+') { julia_args.next(); // consume the +channel argument } } - // Check for non-interactive usage patterns + // -i/--interactive explicitly requests interactive mode, even without a TTY + // or when a script/-e would otherwise be treated as non-interactive. + for arg in julia_args.clone() { + if matches!(arg.as_str(), "-i" | "--interactive") { + return true; + } + } + + // A TTY is a prerequisite for interactivity when -i was not given. + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return false; + } + + // Even with TTY available, check if Julia is being invoked in a non-interactive way for arg in julia_args { match arg.as_str() { // Expression evaluation is non-interactive @@ -694,68 +708,76 @@ fn run_app() -> Result { // On *nix platforms we replace the current process with the Julia one. // This simplifies use in e.g. debuggers, but requires that we fork off - // a subprocess to do the selfupdate and versiondb update. + // a subprocess to do the selfupdate and versiondb update when interactive. #[cfg(not(windows))] - match unsafe { fork() } { - // NOTE: It is unsafe to perform async-signal-unsafe operations from - // forked multithreaded programs, so for complex functionality like - // selfupdate to work julialauncher needs to remain single-threaded. - // Ref: https://docs.rs/nix/latest/nix/unistd/fn.fork.html#safety - Ok(ForkResult::Parent { child, .. }) => { - // wait for the daemon-spawning child to finish - match waitpid(child, None) { - Ok(WaitStatus::Exited(_, code)) => { - if code != 0 { - panic!("Could not fork (child process exited with code: {})", code) + if is_interactive() { + match unsafe { fork() } { + // NOTE: It is unsafe to perform async-signal-unsafe operations from + // forked multithreaded programs, so for complex functionality like + // selfupdate to work julialauncher needs to remain single-threaded. + // Ref: https://docs.rs/nix/latest/nix/unistd/fn.fork.html#safety + Ok(ForkResult::Parent { child, .. }) => { + // wait for the daemon-spawning child to finish + match waitpid(child, None) { + Ok(WaitStatus::Exited(_, code)) => { + if code != 0 { + panic!("Could not fork (child process exited with code: {})", code) + } + } + Ok(_) => { + panic!("Could not fork (child process did not exit normally)"); + } + Err(e) => { + panic!("Could not fork (error waiting for child process, {})", e); } } - Ok(_) => { - panic!("Could not fork (child process did not exit normally)"); - } - Err(e) => { - panic!("Could not fork (error waiting for child process, {})", e); - } - } - // replace the current process - let _ = std::process::Command::new(&julia_path) - .args(&new_args) - .exec(); - - // this is only ever reached if launching Julia fails - panic!( - "Could not launch Julia. Verify that there is a valid Julia binary at \"{}\".", - julia_path.to_string_lossy() - ) - } - Ok(ForkResult::Child) => { - // double-fork to prevent zombies - match unsafe { fork() } { - Ok(ForkResult::Parent { child: _, .. }) => { - // we don't do anything here so that this process can be - // reaped immediately - } - Ok(ForkResult::Child) => { - // this is where we perform the actual work. we don't do - // any typical daemon-y things (like detaching the TTY) - // so that any error output is still visible. + // replace the current process + let _ = std::process::Command::new(&julia_path) + .args(&new_args) + .exec(); - // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child - // process to handle things. - ctrlc::set_handler(|| ()) - .with_context(|| "Failed to set the Ctrl-C handler.")?; + // this is only ever reached if launching Julia fails + panic!( + "Could not launch Julia. Verify that there is a valid Julia binary at \"{}\".", + julia_path.to_string_lossy() + ) + } + Ok(ForkResult::Child) => { + // double-fork to prevent zombies + match unsafe { fork() } { + Ok(ForkResult::Parent { child: _, .. }) => { + // we don't do anything here so that this process can be + // reaped immediately + } + Ok(ForkResult::Child) => { + // this is where we perform the actual work. we don't do + // any typical daemon-y things (like detaching the TTY) + // so that any error output is still visible. - run_versiondb_update(&config_file) - .with_context(|| "Failed to run version db update")?; + // We set a Ctrl-C handler here that just doesn't do anything, as we want the Julia child + // process to handle things. + ctrlc::set_handler(|| ()) + .with_context(|| "Failed to set the Ctrl-C handler.")?; - run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; + run_startup_updates(&config_file)?; + } + Err(_) => panic!("Could not double-fork"), } - Err(_) => panic!("Could not double-fork"), - } - Ok(0) + Ok(0) + } + Err(_) => panic!("Could not fork"), } - Err(_) => panic!("Could not fork"), + } else { + let _ = std::process::Command::new(&julia_path) + .args(&new_args) + .exec(); + + panic!( + "Could not launch Julia. Verify that there is a valid Julia binary at \"{}\".", + julia_path.to_string_lossy() + ) } // On other platforms (i.e., Windows) we just spawn a subprocess @@ -809,9 +831,9 @@ fn run_app() -> Result { ) }; - run_versiondb_update(&config_file).with_context(|| "Failed to run version db update")?; - - run_selfupdate(&config_file).with_context(|| "Failed to run selfupdate.")?; + if is_interactive() { + run_startup_updates(&config_file)?; + } let status = child_process .wait() diff --git a/tests/command_selfupdate_test.rs b/tests/command_selfupdate_test.rs index 9cf1bca1..9f21af4a 100644 --- a/tests/command_selfupdate_test.rs +++ b/tests/command_selfupdate_test.rs @@ -166,6 +166,24 @@ impl Install { } } +/// Run the launcher in a way that `is_interactive` treats as interactive. +/// Uses `-i` so startup updates are exercised without relying on `script(1)`, +/// which does not consistently provide a TTY on all platforms (notably macOS). +fn run_julia_interactive( + julia_symlink: &Path, + env: &TestEnv, + extra_env: &[(&str, &str)], + julia_args: &[&str], +) -> assert_cmd::assert::Assert { + let mut cmd = Command::new(julia_symlink); + env.apply_env(&mut cmd); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.args(julia_args); + cmd.assert() +} + #[test] fn self_update_end_to_end() { let env = TestEnv::new(); @@ -261,10 +279,10 @@ fn self_update_end_to_end() { } /// Verifies the automatic self-update that `julialauncher` triggers at Julia -/// startup: when `startupselfupdateinterval` has elapsed, running `julia` -/// spawns `juliaup self update` in a background daemon (and then execs into -/// Julia). This is the path that historically caused issues because the -/// self-update runs detached from the foreground Julia process. +/// startup in interactive (REPL) mode: when `startupselfupdateinterval` has +/// elapsed, running `julia` spawns `juliaup self update` in a background daemon +/// (and then execs into Julia). Non-interactive invocations such as `julia -e` +/// do not trigger this path. #[test] fn self_update_auto_triggered_by_launcher() { let env = TestEnv::new(); @@ -304,19 +322,21 @@ fn self_update_auto_triggered_by_launcher() { "self-update should not have run before launching julia" ); - // Run the launcher via the `julia` symlink. On Unix it forks a background - // daemon that auto-triggers `juliaup self update` (inheriting the mock - // server env), then execs into Julia which prints its version. The - // self-update proceeds asynchronously after `julia` returns. - let mut julia_cmd = Command::new(&install.julia_symlink); - env.apply_env(&mut julia_cmd); - julia_cmd - .env("JULIAUP_SERVER", &server.base_url) - .env("JULIAUP_NIGHTLY_SERVER", &server.base_url) - .args(["-e", "print(VERSION)"]) - .assert() - .success() - .stdout("1.10.10"); + // Run the launcher via the `julia` symlink with `-i` so startup updates run. + // On Unix it forks a background daemon that auto-triggers `juliaup self update` + // (inheriting the mock server env), then execs into Julia which prints its + // version. The self-update proceeds asynchronously after `julia` returns. + run_julia_interactive( + &install.julia_symlink, + &env, + &[ + ("JULIAUP_SERVER", &server.base_url), + ("JULIAUP_NIGHTLY_SERVER", &server.base_url), + ], + &["-i", "-e", "print(VERSION)"], + ) + .success() + .stdout("1.10.10"); // Wait for the detached self-update to finish: it records the timestamp and // the `_post-update` hook restores the launcher symlink. Poll for the final @@ -345,3 +365,52 @@ fn self_update_auto_triggered_by_launcher() { drop(server); } + +/// Verifies that non-interactive invocations (e.g. `julia -e`) do not trigger +/// background self-update even when the update interval has elapsed. +#[test] +fn self_update_not_triggered_for_non_interactive() { + let env = TestEnv::new(); + let install = Install::setup(); + let juliaup_exe = &install.juliaup_exe; + + let juliaup = || { + let mut cmd = Command::new(juliaup_exe); + env.apply_env(&mut cmd); + cmd + }; + + juliaup() + .args(["config", "versionsdbupdateinterval", "0"]) + .assert() + .success(); + juliaup() + .args(["config", "startupselfupdateinterval", "1"]) + .assert() + .success(); + + juliaup().args(["add", "1.10.10"]).assert().success(); + juliaup().args(["default", "1.10.10"]).assert().success(); + + let bundled_db_version = juliaup::get_bundled_dbversion().unwrap().to_string(); + let tarball = build_juliaup_tarball(&install.juliaup_exe, &install.julialauncher_exe); + let server = MockServer::start(bundled_db_version, "999.0.0".to_string(), tarball); + + let mut julia_cmd = Command::new(&install.julia_symlink); + env.apply_env(&mut julia_cmd); + julia_cmd + .env("JULIAUP_SERVER", &server.base_url) + .env("JULIAUP_NIGHTLY_SERVER", &server.base_url) + .args(["-e", "print(VERSION)"]) + .assert() + .success() + .stdout("1.10.10"); + + thread::sleep(Duration::from_secs(3)); + assert!( + !install.self_update_recorded(), + "self-update should not run for non-interactive `julia -e` invocations" + ); + + drop(server); +}