From 4b852e1a4d3698f4116ba1aa2cb73c4395849df2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:00:20 -0700 Subject: [PATCH 1/7] Add `--jobs` option to limit parallelism --- src/arguments.rs | 7 +++++++ src/config.rs | 3 +++ src/justfile.rs | 20 +++++++++++++++++--- src/lib.rs | 6 ++++-- src/recipe.rs | 3 +++ src/semaphore.rs | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 src/semaphore.rs diff --git a/src/arguments.rs b/src/arguments.rs index d1700d2851..69c58d3f14 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -191,6 +191,13 @@ pub struct Arguments { long )] pub(crate) indentation: Option, + #[arg( + env = "JUST_JOBS", + help = "Spawn at most dependencies in parallel", + long, + value_name = "N" + )] + pub(crate) jobs: Option, #[arg( add = ArgValueCompleter::new(PathCompleter::file()), env = "JUST_JUSTFILE", diff --git a/src/config.rs b/src/config.rs index 771e5dce23..b6e4a5ff0b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ pub(crate) struct Config { pub(crate) highlight: bool, pub(crate) indentation: Option, pub(crate) invocation_directory: PathBuf, + pub(crate) jobs: Option, pub(crate) justfile_names: Option>, pub(crate) list_heading: String, pub(crate) list_prefix: String, @@ -66,6 +67,7 @@ impl Config { highlight: true, indentation: None, invocation_directory: env::current_dir().context(config_error::CurrentDir)?, + jobs: None, justfile_names: None, list_heading: Arguments::DEFAULT_LIST_HEADING.into(), list_prefix: Arguments::DEFAULT_LIST_PREFIX.into(), @@ -342,6 +344,7 @@ impl Config { highlight: !arguments.no_highlight, indentation: arguments.indentation, invocation_directory, + jobs: arguments.jobs, justfile_names: arguments.justfile_names, list_heading: arguments.list_heading, list_prefix: arguments.list_prefix, diff --git a/src/justfile.rs b/src/justfile.rs index 2eb1efcfde..106f74e30f 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -227,6 +227,7 @@ impl<'src> Justfile<'src> { let ran = Ran::new(); let cache = Cache::new(search); + let semaphore = Semaphore::new(config.jobs.unwrap_or(NonZeroU64::MAX)); for invocation in invocations { Self::run_recipe( &invocation.arguments, @@ -238,6 +239,7 @@ impl<'src> Justfile<'src> { &scopes, search, &cache, + &semaphore, )?; } @@ -460,6 +462,7 @@ impl<'src> Justfile<'src> { scopes: &Scopes<'src, '_>, search: &Search, cache: &Cache, + jobs: &Semaphore, ) -> RunResult<'src> { let mutex = ran.mutex(recipe, arguments); @@ -511,9 +514,18 @@ impl<'src> Justfile<'src> { scopes, search, cache, + jobs, )?; - recipe.run(&context, &env, is_dependency, &positional, &scope, cache)?; + recipe.run( + &context, + &env, + is_dependency, + &positional, + &scope, + cache, + jobs, + )?; Self::run_dependencies( config, @@ -526,6 +538,7 @@ impl<'src> Justfile<'src> { scopes, search, cache, + jobs, )?; *guard = true; @@ -544,6 +557,7 @@ impl<'src> Justfile<'src> { scopes: &Scopes<'src, 'run>, search: &Search, cache: &Cache, + jobs: &Semaphore, ) -> RunResult<'src> { if context.config.no_dependencies { return Ok(()); @@ -590,7 +604,7 @@ impl<'src> Justfile<'src> { for (recipe, arguments) in evaluated { handles.push(thread_scope.spawn(move || { Self::run_recipe( - &arguments, config, true, overrides, ran, recipe, scopes, search, cache, + &arguments, config, true, overrides, ran, recipe, scopes, search, cache, jobs, ) })); } @@ -604,7 +618,7 @@ impl<'src> Justfile<'src> { } else { for (recipe, arguments) in evaluated { Self::run_recipe( - &arguments, config, true, overrides, ran, recipe, scopes, search, cache, + &arguments, config, true, overrides, ran, recipe, scopes, search, cache, jobs, )?; } } diff --git a/src/lib.rs b/src/lib.rs index cb9161fe17..87c968e4fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use { search::Search, search_config::SearchConfig, search_error::SearchError, + semaphore::Semaphore, set::Set, setting::Setting, settings::Settings, @@ -159,14 +160,14 @@ pub(crate) use { io::{self, Sink, Write}, iter::{self, FromIterator}, mem, - num::ParseIntError, + num::{NonZeroU64, ParseIntError}, ops::Deref, ops::{Index, RangeInclusive}, path::{self, Component, Path, PathBuf}, process::{self, Command, ExitStatus, Stdio}, slice, str::{self, Chars, FromStr}, - sync::{Arc, LazyLock, Mutex, MutexGuard}, + sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}, thread, time::Instant, vec, @@ -313,6 +314,7 @@ mod scope; mod search; mod search_config; mod search_error; +mod semaphore; mod set; mod setting; mod settings; diff --git a/src/recipe.rs b/src/recipe.rs index 45b87898e0..3b654045bf 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -208,7 +208,10 @@ impl<'src> Recipe<'src> { positional: &[String], scope: &Scope<'src, 'run>, cache: &Cache, + jobs: &Semaphore, ) -> RunResult<'src> { + let _guard = jobs.acquire(); + let color = context.config.color.stderr().banner(); let prefix = color.prefix(); let suffix = color.suffix(); diff --git a/src/semaphore.rs b/src/semaphore.rs new file mode 100644 index 0000000000..69b77564a1 --- /dev/null +++ b/src/semaphore.rs @@ -0,0 +1,38 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct Semaphore(Arc<(Condvar, Mutex)>); + +pub(crate) struct Guard(Semaphore); + +impl Drop for Guard { + fn drop(&mut self) { + *self.0.mutex().lock().unwrap() += 1; + self.0.condvar().notify_one(); + } +} + +impl Semaphore { + pub(crate) fn new(resource: NonZeroU64) -> Self { + Self(Arc::new((Condvar::new(), Mutex::new(resource.into())))) + } + + fn condvar(&self) -> &Condvar { + &self.0.0 + } + + fn mutex(&self) -> &Mutex { + &self.0.1 + } + + pub(crate) fn acquire(&self) -> Guard { + let mut count = self + .condvar() + .wait_while(self.mutex().lock().unwrap(), |count| *count == 0) + .unwrap(); + + *count -= 1; + + Guard(self.clone()) + } +} From 2ebb3d820de1bc1b38887a89de6e2346b83fb75c Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:03:27 -0700 Subject: [PATCH 2/7] Reform --- src/arguments.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/arguments.rs b/src/arguments.rs index 69c58d3f14..a1a1f23bfe 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -193,7 +193,7 @@ pub struct Arguments { pub(crate) indentation: Option, #[arg( env = "JUST_JOBS", - help = "Spawn at most dependencies in parallel", + help = "Run at most recipes in parallel", long, value_name = "N" )] From 9ae9c84cf5a0a5f4f291efb00564fd81dedca5bd Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:04:33 -0700 Subject: [PATCH 3/7] Tweak --- src/justfile.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/justfile.rs b/src/justfile.rs index 106f74e30f..d659a8b535 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -227,7 +227,7 @@ impl<'src> Justfile<'src> { let ran = Ran::new(); let cache = Cache::new(search); - let semaphore = Semaphore::new(config.jobs.unwrap_or(NonZeroU64::MAX)); + let jobs = Semaphore::new(config.jobs.unwrap_or(NonZeroU64::MAX)); for invocation in invocations { Self::run_recipe( &invocation.arguments, @@ -239,7 +239,7 @@ impl<'src> Justfile<'src> { &scopes, search, &cache, - &semaphore, + &jobs, )?; } From 455a5a96a2e15fea952ac7ed982ae35370b18343 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:06:15 -0700 Subject: [PATCH 4/7] Tweak --- src/arguments.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/arguments.rs b/src/arguments.rs index a1a1f23bfe..b73e9f33db 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -193,7 +193,7 @@ pub struct Arguments { pub(crate) indentation: Option, #[arg( env = "JUST_JOBS", - help = "Run at most recipes in parallel", + help = "Run at most recipes simulataneously with the [parallel] attribute", long, value_name = "N" )] From 63f9702634a2d5dc2b49fedde3ae8a0a459a8729 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:06:49 -0700 Subject: [PATCH 5/7] Modify --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f773bebec6..184b865b08 100644 --- a/README.md +++ b/README.md @@ -1493,6 +1493,9 @@ baz: sleep 1 ``` +The number of simultaneously running recipes may be limited with the `--jobs` +option. + GNU `parallel` may be used to run recipe lines concurrently: ```just From d9c70becaad7edd3e9395153ed58f68943085d43 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:17:07 -0700 Subject: [PATCH 6/7] Amend --- README.md | 2 +- src/arguments.rs | 2 +- src/semaphore.rs | 15 +++++----- tests/parallel.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 184b865b08..774c43f4d0 100644 --- a/README.md +++ b/README.md @@ -1494,7 +1494,7 @@ baz: ``` The number of simultaneously running recipes may be limited with the `--jobs` -option. +optionmaster. GNU `parallel` may be used to run recipe lines concurrently: diff --git a/src/arguments.rs b/src/arguments.rs index b73e9f33db..00da69c0ef 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -193,7 +193,7 @@ pub struct Arguments { pub(crate) indentation: Option, #[arg( env = "JUST_JOBS", - help = "Run at most recipes simulataneously with the [parallel] attribute", + help = "Run at most recipes simultaneously with the [parallel] attribute", long, value_name = "N" )] diff --git a/src/semaphore.rs b/src/semaphore.rs index 69b77564a1..7a9388114b 100644 --- a/src/semaphore.rs +++ b/src/semaphore.rs @@ -1,11 +1,10 @@ use super::*; -#[derive(Clone)] -pub(crate) struct Semaphore(Arc<(Condvar, Mutex)>); +pub(crate) struct Semaphore(Condvar, Mutex); -pub(crate) struct Guard(Semaphore); +pub(crate) struct Guard<'a>(&'a Semaphore); -impl Drop for Guard { +impl Drop for Guard<'_> { fn drop(&mut self) { *self.0.mutex().lock().unwrap() += 1; self.0.condvar().notify_one(); @@ -14,15 +13,15 @@ impl Drop for Guard { impl Semaphore { pub(crate) fn new(resource: NonZeroU64) -> Self { - Self(Arc::new((Condvar::new(), Mutex::new(resource.into())))) + Self(Condvar::new(), Mutex::new(resource.into())) } fn condvar(&self) -> &Condvar { - &self.0.0 + &self.0 } fn mutex(&self) -> &Mutex { - &self.0.1 + &self.1 } pub(crate) fn acquire(&self) -> Guard { @@ -33,6 +32,6 @@ impl Semaphore { *count -= 1; - Guard(self.clone()) + Guard(&self) } } diff --git a/tests/parallel.rs b/tests/parallel.rs index a5fa2c61c5..4922ca0764 100644 --- a/tests/parallel.rs +++ b/tests/parallel.rs @@ -138,3 +138,75 @@ fn dependents_block_on_running_dependencies() { ) .success(); } + +#[test] +#[ignore] +fn jobs_limits_concurrent_recipes() { + Test::new() + .args(["--jobs", "1"]) + .justfile( + " + set quiet + + [parallel] + foo: a b + + a: + echo a + sleep 1 + echo a + + b: + echo b + sleep 1 + echo b + ", + ) + .stdout_regex("(a\na\nb\nb\n|b\nb\na\na\n)") + .success(); +} + +#[test] +#[ignore] +fn recipes_up_to_job_limit_run_in_parallel() { + let start = Instant::now(); + + Test::new() + .args(["--jobs", "2"]) + .justfile( + " + [parallel] + foo: a b + + a: + sleep 1 + + b: + sleep 1 + ", + ) + .stderr( + " + sleep 1 + sleep 1 + ", + ) + .success(); + + assert!(start.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn zero_jobs_is_an_error() { + Test::new() + .args(["--jobs", "0"]) + .justfile("") + .stderr( + " + error: invalid value '0' for '--jobs ': number would be zero for non-zero type + + For more information, try '--help'. + ", + ) + .status(2); +} From 3a374cdc28a1a4319577f76c463768fa6b8f8c53 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 3 Jul 2026 20:22:32 -0700 Subject: [PATCH 7/7] Adjust --- src/semaphore.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semaphore.rs b/src/semaphore.rs index 7a9388114b..1058888c5e 100644 --- a/src/semaphore.rs +++ b/src/semaphore.rs @@ -32,6 +32,6 @@ impl Semaphore { *count -= 1; - Guard(&self) + Guard(self) } }