Skip to content
Merged
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
34 changes: 27 additions & 7 deletions devdocs/locking_and_update_flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ modes:
| --- | --- | --- |
| **Shared** (read) | `get_read_lock` / `load_config_db(paths, None)` | only an exclusive holder |
| **Exclusive** (write) | `load_mut_config_db` | any other shared or exclusive holder |
| **None** (lock-free read) | `load_config_db_lockfree` | nothing — never blocks, never blocked |

Many readers can hold the shared lock simultaneously. A single writer holding
the exclusive lock blocks everyone, including readers. The user-visible message
Expand All @@ -31,14 +32,28 @@ common case where another process holds the lock for only a few milliseconds
`load_mut_config_db` route through `lock_with_delayed_message` to get this
behaviour.

### Design rule
### Design rules

> **All writes to `juliaup.json` must replace the file atomically.**

Every writer goes through a temp-file-in-same-directory + rename
(`save_config_db`, and `create_initial_config_file` for the very first
config). This invariant is load-bearing: it is what makes the lock-free read
path (`load_config_db_lockfree`, used by `julialauncher`) safe — a reader
always observes either the old or the new file in full, never a torn write.
Never write to `juliaup.json` in place.

On Windows the rename must go through `std::fs::rename`
(`FILE_RENAME_FLAG_POSIX_SEMANTICS`), not `tempfile`'s `persist()`
(`MoveFileExW`): the latter fails while any process has the destination open,
and with lock-free readers a writer's commit can now overlap a read. See
`persist_atomically` in `config_file.rs`.

> **Never hold the exclusive lock across a network operation.**

Downloads can be slow or hang. A writer that holds the exclusive lock across a
download blocks every concurrent `julia` / `juliaup` invocation (each of which
needs at least a shared lock at startup) for the entire duration of the
download. The commands below follow a common pattern to honour this rule:
download blocks every concurrent `juliaup` invocation (each of which needs at
least a shared lock at startup) for the entire duration of the download. The commands below follow a common pattern to honour this rule:

```mermaid
flowchart LR
Expand All @@ -62,7 +77,7 @@ runs inline before waiting on the Julia child.
```mermaid
flowchart TD
start(["julia +channel ..."]) --> setup["do_initial_setup"]
setup --> read["load_config_db(None)<br/><b>shared lock</b> → released"]
setup --> read["load_config_db_lockfree<br/><b>NO lock</b>"]
read --> resolve["resolve channel → julia_path"]
resolve --> fork{"platform"}

Expand All @@ -83,8 +98,13 @@ flowchart TD

Key points:

- The launcher itself only takes a **shared** lock, briefly, via
`load_config_db`. It is released before Julia is launched.
- The launcher reads the configuration **without taking the lock at all**
(`load_config_db_lockfree`), so launching Julia can never block on the
configuration lock — not even during a slow `remove`/`gc` that holds the
exclusive lock while deleting an installation. This is safe because all
config writes are atomic file replacements (see design rules above). The
only launcher paths that touch the lock are cold and interactive: first-run
setup and the auto-install prompt, both of which spawn `juliaup`.
- `run_versiondb_update` and `run_selfupdate` do **not** run the work inline;
they merely *spawn* `juliaup` subprocesses (subject to their configured
intervals). Those subprocesses do the locking described below.
Expand Down
11 changes: 7 additions & 4 deletions src/bin/julialauncher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use dialoguer::Select;
use is_terminal::IsTerminal;
use itertools::Itertools;
use juliaup::config_file::{
load_config_db, load_mut_config_db, save_config_db, JuliaupConfig, JuliaupConfigChannel,
JuliaupConfigVersion,
load_config_db_lockfree, load_mut_config_db, save_config_db, JuliaupConfig,
JuliaupConfigChannel, JuliaupConfigVersion,
};
use juliaup::global_paths::get_paths;
use juliaup::jsonstructs_versionsdb::JuliaupVersionDB;
Expand Down Expand Up @@ -405,7 +405,7 @@ fn get_julia_path_from_channel(
spawn_juliaup_add(&resolved_channel, paths, is_automatic)?;

// Reload the config to get the newly installed channel
let updated_config_file = load_config_db(paths, None)
let updated_config_file = load_config_db_lockfree(paths)
.with_context(|| "Failed to reload configuration after installing channel.")?;

let updated_channel_info = updated_config_file
Expand Down Expand Up @@ -624,7 +624,10 @@ fn run_app() -> Result<i32> {
do_initial_setup(&paths.juliaupconfig)
.with_context(|| "The Julia launcher failed to run the initial setup steps.")?;

let config_file = load_config_db(&paths, None)
// Read the configuration without taking the configuration lock, so that
// launching Julia can never block on (or be stalled by) the lock. This is
// safe because all config writers replace the file atomically.
let config_file = load_config_db_lockfree(&paths)
.with_context(|| "The Julia launcher failed to load a configuration file.")?;

let versiondb_data = load_versions_db(&paths)
Expand Down
Loading
Loading