Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 85 additions & 63 deletions src/bin/julialauncher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> = 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
Expand Down Expand Up @@ -694,68 +708,76 @@ fn run_app() -> Result<i32> {

// 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
Expand Down Expand Up @@ -809,9 +831,9 @@ fn run_app() -> Result<i32> {
)
};

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()
Expand Down
103 changes: 86 additions & 17 deletions tests/command_selfupdate_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Loading