diff --git a/Werkfile b/Werkfile new file mode 100644 index 00000000..4d9e68ae --- /dev/null +++ b/Werkfile @@ -0,0 +1,13 @@ +config mdbook-flags = "--open" + +# Serve the documentation using mdbook and open it in the default browser. +task mdbook { + let book-dir = "book" + spawn "mdbook serve {mdbook-flags*} -d " +} + +# Install werk-cli using a current installation of Werk. +task install { + let cli = "werk-cli" + run "cargo install --locked --path " +} diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 5d5ea7b5..8d23fda6 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -14,6 +14,7 @@ - [Paths](./paths.md) - [.werk-cache](./werk_cache.md) - [Task recipes](./task_recipes.md) + - [Long-running tasks](./long_running_tasks.md) - [Build recipes](./build_recipes.md) - [When is a target outdated?](./outdatedness.md) - [Depfile support](./depfile_support.md) diff --git a/book/src/features.md b/book/src/features.md index c47450d7..190619e1 100644 --- a/book/src/features.md +++ b/book/src/features.md @@ -43,11 +43,13 @@ - **Concurrency:** Build recipes and tasks run in parallel when possible. -- (TODO) **Autoclean:** Werk is aware of which files it has generated, and can - automatically clean them up from the output directory. +- **Autowatch:** Werk can be run in `--watch` mode, which waits for file changes + and automatically rebuilds when any change is detected. -- (TODO) **Autowatch:** Werk can be run in `--watch` mode, which waits for file - changes and automatically rebuilds when any change is detected. +- **Long-running tasks:** Werk natively supports long-running processes, such as + a development webserver running locally, using the `spawn` statement. This + also works in combination with the `--watch` feature, such that any spawned + processes are automatically restarted when a rebuild is triggered. # Limitations diff --git a/book/src/language/recipe_commands.md b/book/src/language/recipe_commands.md index 0cfdb7b0..67a19d9b 100644 --- a/book/src/language/recipe_commands.md +++ b/book/src/language/recipe_commands.md @@ -48,6 +48,29 @@ arbitrary [expressions](./expressions.md), only strings. However, string interpolation can be used within `run` statements to build command-line invocations from other bits. +## The `spawn` statement + +In task recipes, the `spawn` statement is used to start a long-running process in +the background. Other statements do not wait for the process to finish before executing. + +`spawn` statements may also occur within `run` blocks, meaning that the task can +rely on normal `run` commands having finished before the process is spawned. + +Examples: + +```werk +task my-task { + # Execute a long-running process in the background + spawn "my-server" + + run { + # Execute a normal command before spawning the server + run "hello" + spawn "my-server" + } +} +``` + ## String interpolation in `run` statements Commands executed in recipes, or as part of the `shell` expression, have diff --git a/book/src/long_running_tasks.md b/book/src/long_running_tasks.md new file mode 100644 index 00000000..8ac1a1ee --- /dev/null +++ b/book/src/long_running_tasks.md @@ -0,0 +1,21 @@ +# Long-running tasks + +Task recipes can spawn long-running processes controlled by `werk` using [the +`spawn` statement](./language/recipe_commands.md#the-spawn-statement). This is +useful for running a development server or other long-running processes that +need to be restarted when the source files change. + +When a `spawn` statement has executed, `werk` will wait for the process to exit +before exiting itself. When `werk` receives a Ctrl-C signal, it will kill the +child process as well. + +## Autowatch integration + +When `--watch` is enabled, `werk` will automatically kill and restart any +spawned processes when a rebuild is triggered. + +
+Note: Some programs, such as local webservers, implement their +own watching mechanism. Using these in conjunction with `--watch` may not be desirable, +because `werk` will unconditionally restart the process on any change. +
diff --git a/tests/mock_io.rs b/tests/mock_io.rs index 1fff1e5f..3618be02 100644 --- a/tests/mock_io.rs +++ b/tests/mock_io.rs @@ -1100,6 +1100,10 @@ impl werk_runner::Child for MockChild { > { self.status.take().unwrap() } + + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } } impl werk_runner::Io for MockIo { diff --git a/werk-cli/dry_run.rs b/werk-cli/dry_run.rs index 6aa9352a..fb2c639b 100644 --- a/werk-cli/dry_run.rs +++ b/werk-cli/dry_run.rs @@ -54,6 +54,10 @@ impl Child for DryRunChild { { Box::pin(std::future::ready(Ok(std::process::ExitStatus::default()))) } + + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } } impl werk_runner::Io for DryRun { diff --git a/werk-cli/main.rs b/werk-cli/main.rs index 8edc86f5..84340301 100644 --- a/werk-cli/main.rs +++ b/werk-cli/main.rs @@ -7,12 +7,12 @@ use std::{borrow::Cow, path::Path, sync::Arc}; use ahash::HashSet; use clap::{CommandFactory, Parser}; use clap_complete::ArgValueCandidates; -use futures::future::Either; +use futures::future::{self, Either}; use notify_debouncer_full::notify; use owo_colors::OwoColorize as _; use render::{AutoStream, ColorOutputKind}; use werk_fs::{Absolute, Normalize as _, PathError}; -use werk_runner::{Runner, Warning, Workspace, WorkspaceSettings}; +use werk_runner::{BuildStatus, Runner, Warning, Workspace, WorkspaceSettings}; use werk_util::{Annotated, AsDiagnostic, DiagnosticFileId, DiagnosticSource, DiagnosticSourceMap}; shadow_rs::shadow!(build); @@ -263,30 +263,6 @@ async fn try_main(args: Args) -> Result<(), Error> { return Ok(()); } - let target = args - .target - .clone() - .or_else(|| workspace.default_target.clone()); - let Some(target) = target else { - return Err(Error::NoTarget); - }; - - let runner = Runner::new(&workspace); - let result = runner.build_or_run(&target).await; - - let write_cache = match result { - Ok(_) => true, - Err(ref err) => err.error.should_still_write_werk_cache(), - }; - - if write_cache { - if let Err(err) = workspace.finalize().await { - eprintln!("Error writing `.werk-cache`: {err}") - } - } - - std::mem::drop(runner); - if args.watch { autowatch_loop( std::time::Duration::from_millis(args.watch_delay), @@ -299,14 +275,45 @@ async fn try_main(args: Args) -> Result<(), Error> { .await?; Ok(()) } else { - result.map(|_| ()).map_err(print_error) + let target = args + .target + .clone() + .or_else(|| workspace.default_target.clone()); + let Some(target) = target else { + workspace + .render + .runner_message("No configured default target"); + return Err(Error::NoTarget); + }; + + let (ctrlc_sender, ctrlc_receiver) = smol::channel::bounded(1); + _ = ctrlc::set_handler(move || { + _ = ctrlc_sender.try_send(()); + }); + let ctrlc_recv = ctrlc_receiver.recv(); + smol::pin!(ctrlc_recv); + + let (runner, result) = run(&workspace, &target).await; + result?; + let wait = runner.wait_for_long_running_tasks(); + smol::pin!(wait); + + match future::select(wait, ctrlc_recv).await { + Either::Left(_) => Ok(()), + Either::Right(_) => { + // Stop children, giving child processes a chance to finish + // cleanly (and giving us a chance to read their stdout/stderr). + runner.stop(std::time::Duration::from_millis(100)).await; + Ok(()) + } + } } } async fn autowatch_loop( timeout: std::time::Duration, // The initial workspace built by main(). Must be finalize()d. - workspace: Workspace, + mut workspace: Workspace, werkfile: Absolute, // Target to keep building target_from_args: Option, @@ -332,11 +339,30 @@ async fn autowatch_loop( } })); let workspace_dir = workspace.project_root().to_path_buf(); - std::mem::drop(workspace); let mut settings = settings.clone(); + let mut target = target_from_args + .clone() + .or_else(|| workspace.default_target.clone()); + loop { + if target.is_none() { + render.runner_message("No configured default target"); + watch_set = watch_manifest.clone(); + } + + // Start the notifier. + let notifier = make_notifier_for_files(&watch_set, notification_sender.clone(), timeout)?; + let notification_recv = notification_receiver.recv(); + + // Build or rebuild the target! + let runner_and_result = if let Some(target) = target.as_ref() { + Some(run(&workspace, target).await) + } else { + None + }; + if watch_set == watch_manifest { render.runner_message("Watching manifest for changes, press Ctrl-C to stop"); } else { @@ -346,14 +372,11 @@ async fn autowatch_loop( )); } - // Start the notifier. - let notifier = make_notifier_for_files(&watch_set, notification_sender.clone(), timeout)?; - let notification_recv = notification_receiver.recv(); let ctrlc_recv = ctrlc_receiver.recv(); smol::pin!(notification_recv); smol::pin!(ctrlc_recv); - match futures::future::select(notification_recv, ctrlc_recv).await { + match future::select(notification_recv, ctrlc_recv).await { Either::Left((result, _)) => result.expect("notifier channel error"), Either::Right((result, _)) => { if result.is_ok() { @@ -363,6 +386,15 @@ async fn autowatch_loop( } } + // If there are any long-running tasks, give them a chance to finish. + // Note that `run()` automatically does this if the target could not be + // built. + if let Some((ref runner, Ok(_))) = runner_and_result { + runner.stop(std::time::Duration::from_millis(100)).await; + } + + std::mem::drop(runner_and_result); + // Stop the notifier again immediately. TODO: Consider if it makes sense to reuse it. notifier.stop(); @@ -430,7 +462,7 @@ async fn autowatch_loop( settings.output_directory = out_dir; } - let mut workspace = + workspace = match Workspace::new(io.clone(), render.clone(), workspace_dir.clone(), &settings) { Ok(workspace) => workspace, Err(err) => { @@ -453,14 +485,9 @@ async fn autowatch_loop( } } - let target = target_from_args + target = target_from_args .clone() .or_else(|| workspace.default_target.clone()); - let Some(target) = target else { - render.runner_message("No configured default target"); - watch_set = watch_manifest.clone(); - continue; - }; // Update the watchset. watch_set.clear(); @@ -472,25 +499,31 @@ async fn autowatch_loop( None } })); + } +} - // Finally, rebuild the target! - let runner = Runner::new(&workspace); - let write_cache = match runner.build_or_run(&target).await { - Ok(_) => true, - Err(err) => { - let write_cache = err.error.should_still_write_werk_cache(); - print_error(err); - write_cache - } - }; +async fn run<'w>( + workspace: &'w Workspace, + target: &str, +) -> (Runner<'w>, Result) { + let runner = Runner::new(workspace); + let result = runner.build_or_run(target).await; - if write_cache { - if let Err(err) = workspace.finalize().await { - eprintln!("Error writing `.werk-cache`: {err}"); - return Err(err.into()); - } + let write_cache = match result { + Ok(_) => true, + Err(ref err) => { + runner.stop(std::time::Duration::from_secs(1)).await; + err.error.should_still_write_werk_cache() + } + }; + + if write_cache { + if let Err(err) = workspace.finalize().await { + eprintln!("Error writing `.werk-cache`: {err}") } } + + (runner, result.map_err(print_error)) } fn make_notifier_for_files( diff --git a/werk-parser/ast.rs b/werk-parser/ast.rs index 91b3124a..ec849f00 100644 --- a/werk-parser/ast.rs +++ b/werk-parser/ast.rs @@ -417,6 +417,7 @@ pub enum TaskRecipeStmt { Let(LetStmt), Build(BuildStmt), Run(RunStmt), + Spawn(SpawnExpr), Info(InfoExpr), Warn(WarnExpr), SetCapture(KwExpr), @@ -432,6 +433,7 @@ impl SemanticHash for TaskRecipeStmt { TaskRecipeStmt::Let(stmt) => stmt.semantic_hash(state), TaskRecipeStmt::Build(stmt) => stmt.semantic_hash(state), TaskRecipeStmt::Run(stmt) => stmt.semantic_hash(state), + TaskRecipeStmt::Spawn(stmt) => stmt.semantic_hash(state), TaskRecipeStmt::Env(stmt) => stmt.semantic_hash(state), TaskRecipeStmt::EnvRemove(stmt) => stmt.semantic_hash(state), // Information statements do not contribute to outdatedness. @@ -524,6 +526,7 @@ pub type FromStmt = KwExpr; pub type BuildStmt = KwExpr; pub type DepfileStmt = KwExpr; pub type RunStmt = KwExpr; +pub type SpawnExpr = KwExpr; pub type ErrorStmt = KwExpr; pub type DeleteExpr = KwExpr; pub type TouchExpr = KwExpr; @@ -535,6 +538,8 @@ pub type EnvRemoveStmt = KwExpr; pub enum RunExpr { /// Run shell command. Shell(ShellExpr), + /// Spawn a shell command without waiting for it to finish. + Spawn(SpawnExpr), /// Write the result of the expression to the path. The string is an OS path. Write(WriteExpr), /// Copy one file to another. @@ -561,6 +566,7 @@ impl Spanned for RunExpr { fn span(&self) -> Span { match self { RunExpr::Shell(expr) => expr.span, + RunExpr::Spawn(expr) => expr.span, RunExpr::Write(expr) => expr.span, RunExpr::Copy(expr) => expr.span, RunExpr::Delete(expr) => expr.span, @@ -580,6 +586,7 @@ impl SemanticHash for RunExpr { std::mem::discriminant(self).hash(state); match self { RunExpr::Shell(expr) => expr.semantic_hash(state), + RunExpr::Spawn(expr) => expr.semantic_hash(state), RunExpr::Write(expr) => expr.semantic_hash(state), RunExpr::Copy(expr) => expr.semantic_hash(state), RunExpr::Delete(expr) => expr.semantic_hash(state), diff --git a/werk-parser/ast/keyword.rs b/werk-parser/ast/keyword.rs index 2d75c317..c318d17b 100644 --- a/werk-parser/ast/keyword.rs +++ b/werk-parser/ast/keyword.rs @@ -124,6 +124,7 @@ def_keyword!(True, "true"); // Run commands def_keyword!(Run, "run"); +def_keyword!(Spawn, "spawn"); def_keyword!(To, "to"); def_keyword!(Copy, "copy"); def_keyword!(Write, "write"); diff --git a/werk-parser/error.rs b/werk-parser/error.rs index 9af87e64..6324b7d3 100644 --- a/werk-parser/error.rs +++ b/werk-parser/error.rs @@ -60,6 +60,13 @@ impl Error { pub fn with_file_ref(&self, file: DiagnosticFileId) -> ErrorInFile<&Self> { ErrorInFile { error: self, file } } + + #[inline] + #[must_use] + pub fn with_hint(mut self, hint: &'static str) -> Self { + self.push(self.offset, ErrContext::Hint(hint)); + self + } } impl AsRef for Error { @@ -134,6 +141,12 @@ impl> werk_util::AsDiagnostic for ErrorInFile { .annotations(context.chain(Some( span.annotation(level, format_args!("expected {expected}")), ))), + Failure::Unexpected(unexpected) => level + .diagnostic("P2001") + .title("parse error") + .annotations(context.chain(Some( + span.annotation(level, format_args!("unexpected {unexpected}")), + ))), Failure::ExpectedKeyword(expected) => level .diagnostic("P1002") .title("parse error") @@ -190,6 +203,8 @@ pub enum Failure { /// "expected ..." #[error("expected {0}")] Expected(&'static &'static str), + #[error("unexpected {0}")] + Unexpected(&'static &'static str), #[error("expected keyword `{0}`")] ExpectedKeyword(&'static &'static str), #[error("invalid escape sequence: {0:?}")] @@ -205,12 +220,20 @@ pub enum Failure { } impl winnow::error::FromExternalError, std::num::ParseIntError> for ModalErr { + #[inline] fn from_external_error(input: &Input<'_>, e: std::num::ParseIntError) -> Self { let offset = Offset(input.current_token_start() as u32); ModalErr::Backtrack(offset, Failure::ParseInt(e)) } } +impl winnow::error::FromExternalError, ModalErr> for ModalErr { + #[inline] + fn from_external_error(_input: &Input<'_>, e: ModalErr) -> Self { + e + } +} + #[derive(Clone, Copy, Debug, thiserror::Error, PartialEq)] pub enum ErrContext { #[error("{0}")] diff --git a/werk-parser/parser.rs b/werk-parser/parser.rs index d970030c..8a0944de 100644 --- a/werk-parser/parser.rs +++ b/werk-parser/parser.rs @@ -335,7 +335,8 @@ impl Parse for ast::TaskRecipeStmt { alt(( parse.map(ast::TaskRecipeStmt::Let), parse.map(ast::TaskRecipeStmt::Build), - parse.map(ast::TaskRecipeStmt::Run), + parse_run_stmt::.map(ast::TaskRecipeStmt::Run), + parse.map(ast::TaskRecipeStmt::Spawn), parse.map(ast::TaskRecipeStmt::EnvRemove), parse.map(ast::TaskRecipeStmt::Env), parse.map(ast::TaskRecipeStmt::Info), @@ -343,7 +344,7 @@ impl Parse for ast::TaskRecipeStmt { parse.map(ast::TaskRecipeStmt::SetCapture), parse.map(ast::TaskRecipeStmt::SetNoCapture), fatal(Failure::Expected(&"task recipe statement")).help( - "could be one of `let`, `from`, `build`, `depfile`, `run`, or `echo` statement", + "could be one of `let`, `from`, `build`, `depfile`, `run`, `spawn`, `info`, or `warn` statement", ), )) .parse_next(input) @@ -376,7 +377,7 @@ impl Parse for ast::BuildRecipeStmt { parse.map(ast::BuildRecipeStmt::From), parse.map(ast::BuildRecipeStmt::Let), parse.map(ast::BuildRecipeStmt::Depfile), - parse.map(ast::BuildRecipeStmt::Run), + parse_run_stmt::.map(ast::BuildRecipeStmt::Run), parse.map(ast::BuildRecipeStmt::EnvRemove), parse.map(ast::BuildRecipeStmt::Env), parse.map(ast::BuildRecipeStmt::Info), @@ -490,30 +491,41 @@ fn env_stmt(input: &mut Input) -> PResult { Ok(stmt) } -impl Parse for ast::KwExpr +/// ` ` +/// +/// If the keyword is successfully parsed, parse the param with `cut_err(...)`, +/// so no backtracking. +fn kw_expr<'a, K, Param>( + mut inner: impl Parser<'a, Param> + Copy, +) -> impl Parser<'a, ast::KwExpr> where - T: keyword::Keyword + Parse, - Param: Parse, + K: keyword::Keyword + Parse, { - /// ` ` - /// - /// If the keyword is successfully parsed, parse the param with `cut_err(...)`, - /// so no backtracking. - fn parse(input: &mut Input) -> PResult { + move |input: &mut Input<'a>| { let (mut expr, span) = seq! { ast::KwExpr { span: default, token: parse, ws_1: whitespace_nonempty, - param: cut_err(parse), + param: cut_err(inner.by_ref()), }} .with_token_span() - .while_parsing(T::TOKEN) + .while_parsing(K::TOKEN) .parse_next(input)?; expr.span = span; Ok(expr) } } +impl Parse for ast::KwExpr +where + T: keyword::Keyword + Parse, + Param: Parse, +{ + fn parse(input: &mut Input) -> PResult { + kw_expr(parse).parse_next(input) + } +} + impl Parse for ast::Expr { fn parse(input: &mut Input) -> PResult { let expr = alt(( @@ -673,35 +685,71 @@ fn expression_chain_op(input: &mut Input) -> PResult { .parse_next(input) } -impl Parse for ast::RunExpr { - fn parse(input: &mut Input) -> PResult { - alt(( - parse.map(|string: ast::StringExpr| { - ast::RunExpr::Shell(ast::ShellExpr { - span: string.span, - token: keyword::Keyword::with_span(string.span), - ws_1: ws_ignore(), - param: string, - }) - }), - parse.map(ast::RunExpr::List), - parse.map(ast::RunExpr::Shell), - parse.map(ast::RunExpr::Info), - parse.map(ast::RunExpr::Warn), - parse.map(ast::RunExpr::Write), - parse.map(ast::RunExpr::Copy), - parse.map(ast::RunExpr::Delete), - parse.map(ast::RunExpr::Touch), - parse.map(ast::RunExpr::EnvRemove), - parse.map(ast::RunExpr::Env), - parse.map(ast::RunExpr::Block), - fatal(Failure::Expected(&"a run expression")) - .help("one of `shell`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block") +fn parse_run_stmt(input: &mut Input<'_>) -> PResult { + kw_expr(parse_run_expr::).parse_next(input) +} + +fn parse_run_expr(input: &mut Input<'_>) -> PResult { + alt(( + parse.map(|string: ast::StringExpr| { + ast::RunExpr::Shell(ast::ShellExpr { + span: string.span, + token: keyword::Keyword::with_span(string.span), + ws_1: ws_ignore(), + param: string, + }) + }), + parse_run_expr_list::.map(ast::RunExpr::List), + parse.map(ast::RunExpr::Shell), + parse_spawn_expr::.map(ast::RunExpr::Spawn), + parse.map(ast::RunExpr::Info), + parse.map(ast::RunExpr::Warn), + parse.map(ast::RunExpr::Write), + parse.map(ast::RunExpr::Copy), + parse.map(ast::RunExpr::Delete), + parse.map(ast::RunExpr::Touch), + parse.map(ast::RunExpr::EnvRemove), + parse.map(ast::RunExpr::Env), + parse_run_expr_block::.map(ast::RunExpr::Block), + fatal(Failure::Expected(&"a run expression")) + .help(if ALLOW_SPAWN { + "one of `shell`, `spawn`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block" + } else { "one of `shell`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block"} ) + )).parse_next(input) +} + +fn parse_spawn_expr(input: &mut Input) -> PResult { + let expr = parse.parse_next(input)?; + if ALLOW_SPAWN { + Ok(expr) + } else { + Err(ModalErr::Error( + Error::new(expr.span.start, Failure::Unexpected(&"spawn statement")) + .with_hint("`spawn` is only allowed in `task` recipes"), )) - .parse_next(input) } } +fn parse_run_expr_list( + input: &mut Input<'_>, +) -> PResult> { + parse_list_expr(parse_run_expr::).parse_next(input) +} + +fn parse_run_expr_block( + input: &mut Input<'_>, +) -> PResult> { + let (token_open, statements, decor_trailing, token_close) = + statements_delimited(parse, parse_run_expr::, parse).parse_next(input)?; + + Ok(ast::Body { + token_open, + statements, + ws_trailing: decor_trailing, + token_close, + }) +} + impl Parse for ast::WriteExpr { fn parse(input: &mut Input) -> PResult { let (mut expr, span) = seq! {ast::WriteExpr { @@ -809,8 +857,10 @@ impl Parse for ast::MatchBody { } } -impl Parse for ast::ListExpr { - fn parse(input: &mut Input) -> PResult { +fn parse_list_expr<'a, Inner: Parser<'a, T>, T>( + mut inner: Inner, +) -> impl Parser<'a, ast::ListExpr> { + move |input: &mut Input<'a>| { let token_open = parse::.parse_next(input)?; let mut accum = Vec::new(); @@ -837,7 +887,7 @@ impl Parse for ast::ListExpr { ))); } - let item = parse.parse_next(input)?; + let item = inner.parse_next(input)?; end_of_last_item = input.checkpoint(); let whitespace_before_comma = whitespace.parse_next(input)?; @@ -877,6 +927,12 @@ impl Parse for ast::ListExpr { } } +impl Parse for ast::ListExpr { + fn parse(input: &mut Input) -> PResult { + parse_list_expr(parse).parse_next(input) + } +} + impl Parse for ast::Ident { fn parse(input: &mut Input) -> PResult { fn identifier_chars<'a>(input: &mut Input<'a>) -> PResult<&'a str> { diff --git a/werk-parser/tests/fail/spawn_in_build_recipe.txt b/werk-parser/tests/fail/spawn_in_build_recipe.txt new file mode 100644 index 00000000..8c43171f --- /dev/null +++ b/werk-parser/tests/fail/spawn_in_build_recipe.txt @@ -0,0 +1,9 @@ +error[P1001]: parse error + --> INPUT:1:1 + | +1 | build "foo" { + | - info: while parsing build recipe +2 | spawn "bar" + | ^ expected build recipe statement + | + = help: could be one of `let`, `from`, `build`, `depfile`, `run`, or `echo` statement diff --git a/werk-parser/tests/fail/spawn_in_build_recipe.werk b/werk-parser/tests/fail/spawn_in_build_recipe.werk new file mode 100644 index 00000000..55017dd2 --- /dev/null +++ b/werk-parser/tests/fail/spawn_in_build_recipe.werk @@ -0,0 +1,3 @@ +build "foo" { + spawn "bar" +} diff --git a/werk-parser/tests/fail/spawn_in_build_recipe_run_block.txt b/werk-parser/tests/fail/spawn_in_build_recipe_run_block.txt new file mode 100644 index 00000000..bb628d99 --- /dev/null +++ b/werk-parser/tests/fail/spawn_in_build_recipe_run_block.txt @@ -0,0 +1,11 @@ +error[P2001]: parse error + --> INPUT:2:5 + | +1 | build "foo" { + | - info: while parsing build recipe +2 | run { + | - info: while parsing run +3 | spawn "bar" + | ^ unexpected spawn statement + | + = help: `spawn` is only allowed in `task` recipes diff --git a/werk-parser/tests/fail/spawn_in_build_recipe_run_block.werk b/werk-parser/tests/fail/spawn_in_build_recipe_run_block.werk new file mode 100644 index 00000000..c89044c0 --- /dev/null +++ b/werk-parser/tests/fail/spawn_in_build_recipe_run_block.werk @@ -0,0 +1,5 @@ +build "foo" { + run { + spawn "bar" + } +} diff --git a/werk-parser/tests/succeed/spawn.json b/werk-parser/tests/succeed/spawn.json new file mode 100644 index 00000000..58cbb7e3 --- /dev/null +++ b/werk-parser/tests/succeed/spawn.json @@ -0,0 +1,12 @@ +[ + { + "Task": { + "name": "server", + "body": [ + { + "Spawn": "my-server" + } + ] + } + } +] diff --git a/werk-parser/tests/succeed/spawn.werk b/werk-parser/tests/succeed/spawn.werk new file mode 100644 index 00000000..a1d98be8 --- /dev/null +++ b/werk-parser/tests/succeed/spawn.werk @@ -0,0 +1,3 @@ +task server { + spawn "my-server" +} diff --git a/werk-parser/tests/test_cases.rs b/werk-parser/tests/test_cases.rs index 47048bb9..f60ba2ed 100644 --- a/werk-parser/tests/test_cases.rs +++ b/werk-parser/tests/test_cases.rs @@ -116,6 +116,8 @@ error_case!(match_unterminated); error_case!(match_no_arrow); error_case!(default_unknown_key); error_case!(default_invalid_value); +error_case!(spawn_in_build_recipe); +error_case!(spawn_in_build_recipe_run_block); success_case!(c); success_case!(config); @@ -126,3 +128,4 @@ success_case!(let_match_inline); success_case!(let_map); success_case!(let_list); success_case!(expr_parens); +success_case!(spawn); diff --git a/werk-runner/eval.rs b/werk-runner/eval.rs index faeb0bc5..90d2c40f 100644 --- a/werk-runner/eval.rs +++ b/werk-runner/eval.rs @@ -610,6 +610,11 @@ pub(crate) fn eval_run_exprs( *used |= shell.used; commands.push(RunCommand::Shell(shell.value)); } + ast::RunExpr::Spawn(expr) => { + let shell = eval_shell_command(scope, &expr.param, file)?; + *used |= shell.used; + commands.push(RunCommand::Spawn(shell.value)); + } ast::RunExpr::Write(expr) => { let destination = eval(scope, &expr.path, file)?; let Value::String(dest_path) = destination.value else { @@ -1014,6 +1019,10 @@ pub(crate) fn eval_task_recipe_statements( ast::TaskRecipeStmt::Run(ref expr) => { eval_run_exprs(scope, &expr.param, &mut evaluated.commands, file)?; } + ast::TaskRecipeStmt::Spawn(ref expr) => { + let command = eval_shell_command(scope, &expr.param, file)?; + evaluated.commands.push(RunCommand::Spawn(command.value)); + } ast::TaskRecipeStmt::Info(ref expr) => { let message = eval_string_expr(scope, &expr.param, file)?; evaluated diff --git a/werk-runner/io/child.rs b/werk-runner/io/child.rs index 9683f593..d3777cdd 100644 --- a/werk-runner/io/child.rs +++ b/werk-runner/io/child.rs @@ -21,6 +21,8 @@ pub trait Child: Send + Sync + Unpin { fn status( &mut self, ) -> Pin> + Send>>; + + fn kill(&mut self) -> std::io::Result<()>; } impl Child for smol::process::Child { @@ -52,6 +54,10 @@ impl Child for smol::process::Child { { Box::pin(self.status()) } + + fn kill(&mut self) -> std::io::Result<()> { + self.kill() + } } pub enum ChildCaptureOutput { @@ -109,6 +115,12 @@ impl ChildLinesStream { status, } } + + pub async fn wait(mut self) -> io::Result { + self.stdout.take(); + self.stderr.take(); + self.status.await + } } impl Stream for ChildLinesStream { diff --git a/werk-runner/runner.rs b/werk-runner/runner.rs index ac2dfddf..7c0fcd5e 100644 --- a/werk-runner/runner.rs +++ b/werk-runner/runner.rs @@ -1,10 +1,10 @@ use std::{future::Future, sync::Arc, time::SystemTime}; -use futures::{StreamExt, channel::oneshot}; +use futures::{StreamExt, channel::oneshot, future}; use indexmap::{IndexMap, map::Entry}; use parking_lot::Mutex; use werk_fs::{Absolute, Normalize as _, Path, SymPath}; -use werk_util::{Annotated, AsDiagnostic, DiagnosticSpan, Symbol}; +use werk_util::{Annotated, AsDiagnostic, DiagnosticSpan, Symbol, cancel}; use crate::{ AmbiguousPatternError, BuildRecipeScope, ChildCaptureOutput, ChildLinesStream, Env, Error, @@ -15,6 +15,14 @@ use crate::{ ir, }; +mod command; +mod dep_chain; +mod task; + +pub use command::*; +pub use dep_chain::*; +pub use task::*; + /// Workspace-wide runner state. pub(crate) struct RunnerState { concurrency_limit: smol::lock::Semaphore, @@ -36,7 +44,10 @@ pub struct Runner<'a> { struct Inner<'a> { workspace: &'a Workspace, + /// Executor for running tasks. All tasks here *must* subscribe to the + /// cancellation signal, or deadlocks can occur. executor: smol::Executor<'a>, + cancel: cancel::Sender, } #[derive(Clone)] @@ -85,131 +96,13 @@ impl BuildStatus { } } -enum TaskStatus { - Built(Result), - Pending(Vec>>), -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TaskId { - Task(Symbol), - // TODO: When recipes can build multiple files, this needs to change to some - // ID that encapsulates the "recipe instance" rather than the path of a - // single target. - Build(Absolute), -} - -impl TaskId { - pub fn command(s: impl Into) -> Self { - let name = s.into(); - debug_assert!(!name.as_str().starts_with('/')); - TaskId::Task(name) - } - - pub fn build(p: impl AsRef>) -> Self { - TaskId::Build(Absolute::symbolicate(p)) - } - - pub fn try_build

(p: P) -> Result - where - P: TryInto>, - { - let path = p.try_into()?; - Ok(TaskId::build(path)) - } - - #[inline] - #[must_use] - pub fn is_command(&self) -> bool { - matches!(self, TaskId::Task(_)) - } - - #[inline] - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - TaskId::Task(task) => task.as_str(), - TaskId::Build(build) => build.as_inner().as_str(), - } - } - - #[inline] - #[must_use] - pub fn as_path(&self) -> Option<&Absolute> { - if let TaskId::Build(build) = self { - Some(build.as_path()) - } else { - None - } - } - - #[inline] - #[must_use] - pub fn short_name(&self) -> &'static str { - match self { - TaskId::Task(task) => task.as_str(), - TaskId::Build(path) => { - let Some((_prefix, filename)) = path - .as_inner() - .as_str() - .rsplit_once(werk_fs::Path::SEPARATOR) - else { - // The path is absolute. - unreachable!() - }; - filename - } - } - } -} - -impl std::fmt::Display for TaskId { - #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -enum TaskSpec<'a> { - Recipe(ir::RecipeMatch<'a>), - CheckExists(Absolute), - /// Check if the file exists, but don't emit an error if it doesn't. This - /// applies to dependencies discovered through depfiles, where the depfile - /// may be outdated (from a previous build). - /// - /// If the file does not exist, the task will be considered outdated. - CheckExistsRelaxed(Absolute), -} - -enum DepfileSpec<'a> { - /// The depfile is explicitly generated by a recipe. - Recipe(ir::BuildRecipeMatch<'a>), - /// The depfile is implicitly generated by the associated command recipe. - ImplicitlyGenerated(Absolute), -} - -impl TaskSpec<'_> { - pub fn to_task_id(&self) -> TaskId { - match self { - TaskSpec::Recipe(ir::RecipeMatch::Build(build_recipe_match)) => { - TaskId::build(build_recipe_match.target_file.clone()) - } - TaskSpec::Recipe(ir::RecipeMatch::Task(task_recipe_match)) => { - TaskId::command(task_recipe_match.name) - } - TaskSpec::CheckExists(path_buf) | TaskSpec::CheckExistsRelaxed(path_buf) => { - TaskId::build(path_buf.clone().into_boxed_path()) - } - } - } -} - impl<'a> Runner<'a> { pub fn new(workspace: &'a Workspace) -> Self { Self { inner: Arc::new(Inner { workspace, executor: smol::Executor::new(), + cancel: cancel::Sender::new(), }), } } @@ -271,6 +164,31 @@ impl<'a> Runner<'a> { .await .map_err(|err| err.into_diagnostic_error(self.inner.workspace)) } + + /// Stop long-running child processes and wait for them to finish. + pub async fn stop(&self, timeout: std::time::Duration) { + self.inner.cancel.cancel(); + let timer = smol::Timer::after(timeout); + + let wait = self.wait_for_long_running_tasks(); + smol::pin!(wait); + + match future::select(wait, timer).await { + future::Either::Left(_) => (), + future::Either::Right(_) => { + self.inner + .workspace + .render + .warning(None, &Warning::ZombieChild); + } + } + } + + pub async fn wait_for_long_running_tasks(&self) { + while !self.inner.executor.is_empty() { + self.inner.executor.tick().await; + } + } } impl<'a> Inner<'a> { @@ -404,9 +322,10 @@ impl<'a> Inner<'a> { receiver.await.map_err(|_| Error::Cancelled(task_id))? } Scheduling::BuildNow(task_spec) => { + let cancel = self.cancel.receiver(); let result = self .clone() - .rebuild_spec(task_id, task_spec, dep_chain) + .rebuild_spec(task_id, task_spec, &cancel, dep_chain) .await; finish_built(&self.workspace.runner_state, task_id, &result); result @@ -453,6 +372,7 @@ impl<'a> Inner<'a> { self: &Arc, task_id: TaskId, recipe_match: ir::BuildRecipeMatch<'_>, + cancel: &cancel::Receiver, dep_chain: DepChainEntry<'_>, ) -> Result { let mut scope = BuildRecipeScope::new(self.workspace, task_id, &recipe_match); @@ -597,9 +517,16 @@ impl<'a> Inner<'a> { let result = if outdated.is_outdated() { tracing::debug!("Rebuilding"); tracing::trace!("Reasons: {:?}", outdated); - self.execute_recipe_commands(task_id, evaluated.commands, evaluated.env, true, false) - .await - .map(|()| BuildStatus::Complete(task_id, outdated)) + self.execute_recipe_commands( + task_id, + cancel, + evaluated.commands, + evaluated.env, + true, + false, + ) + .await + .map(|()| BuildStatus::Complete(task_id, outdated)) } else { tracing::debug!("Up to date"); Ok(BuildStatus::Complete(task_id, outdated)) @@ -633,6 +560,7 @@ impl<'a> Inner<'a> { self: &Arc, task_id: TaskId, recipe: &ir::TaskRecipe, + cancel: &cancel::Receiver, dep_chain: DepChainEntry<'_>, ) -> Result { let mut scope = TaskRecipeScope::new(self.workspace, task_id); @@ -660,7 +588,14 @@ impl<'a> Inner<'a> { .will_build(task_id, evaluated.commands.len(), &outdated); let result = self - .execute_recipe_commands(task_id, evaluated.commands, evaluated.env, false, true) + .execute_recipe_commands( + task_id, + cancel, + evaluated.commands, + evaluated.env, + false, + true, + ) .await .map(|()| BuildStatus::Complete(task_id, outdated)); @@ -671,6 +606,7 @@ impl<'a> Inner<'a> { async fn execute_recipe_commands( &self, task_id: TaskId, + cancel: &cancel::Receiver, run_commands: Vec, mut env: Env, silent_by_default: bool, @@ -712,9 +648,22 @@ impl<'a> Inner<'a> { step, num_steps, forward_stdout, + cancel, ) .await?; } + RunCommand::Spawn(command_line) => { + self.execute_recipe_spawn_command( + task_id, + &command_line, + &env, + silent, + step, + num_steps, + forward_stdout, + cancel, + )?; + } RunCommand::Write(path_buf, vec) => { self.workspace.io.write_file(&path_buf, &vec)?; } @@ -756,7 +705,7 @@ impl<'a> Inner<'a> { } if let Some(delay) = self.workspace.artificial_delay { - smol::Timer::after(delay).await; + future::select(cancel, smol::Timer::after(delay)).await; } } @@ -773,6 +722,7 @@ impl<'a> Inner<'a> { step: usize, num_steps: usize, forward_stdout: bool, + cancel: &cancel::Receiver, ) -> Result<(), Error> { self.workspace .render @@ -788,9 +738,15 @@ impl<'a> Inner<'a> { // interested in the output. let mut reader = ChildLinesStream::new(&mut *child, true); let result = loop { - match reader.next().await { - Some(Err(err)) => break Err(err), - Some(Ok(output)) => match output { + let next = reader.next(); + match future::select(cancel, next).await { + future::Either::Left((_canceled, _)) => { + _ = child.kill(); + _ = reader.wait().await; + return Err(Error::Cancelled(task_id)); + } + future::Either::Right((Some(Err(err)), _)) => break Err(err), + future::Either::Right((Some(Ok(output)), _)) => match output { ChildCaptureOutput::Stdout(line) => { self.workspace.render.on_child_process_stdout_line( task_id, @@ -808,7 +764,9 @@ impl<'a> Inner<'a> { } ChildCaptureOutput::Exit(status) => break Ok(status), }, - None => panic!("child process stream ended without an exit status"), + future::Either::Right((None, _)) => { + panic!("child process stream ended without an exit status") + } } }; @@ -822,6 +780,75 @@ impl<'a> Inner<'a> { Ok(()) } + #[expect(clippy::too_many_arguments)] + fn execute_recipe_spawn_command( + &self, + task_id: TaskId, + command_line: &ShellCommandLine, + env: &Env, + capture: bool, + step: usize, + num_steps: usize, + forward_stdout: bool, + cancel: &cancel::Receiver, + ) -> Result<(), Error> { + self.workspace + .render + .will_execute(task_id, command_line, step, num_steps); + let mut child = self.workspace.io.run_recipe_command( + command_line, + self.workspace.project_root(), + env, + forward_stdout, + )?; + + let render = self.workspace.render.clone(); + let command_line = command_line.clone(); + let cancel = cancel.clone(); + + self.executor + .spawn(async move { + let mut reader = ChildLinesStream::new(&mut *child, true); + let result = loop { + let next = reader.next(); + match future::select(&cancel, next).await { + future::Either::Left((_canceled, _)) => { + render.message( + Some(task_id), + &format!("Terminating spawned: {command_line}",), + ); + _ = child.kill(); + _ = reader.wait().await; + return; + } + future::Either::Right((Some(Err(err)), _)) => break Err(err), + future::Either::Right((Some(Ok(output)), _)) => match output { + ChildCaptureOutput::Stdout(line) => { + render.on_child_process_stdout_line(task_id, &command_line, &line); + } + ChildCaptureOutput::Stderr(line) => { + render.on_child_process_stderr_line( + task_id, + &command_line, + &line, + capture, + ); + } + ChildCaptureOutput::Exit(status) => break Ok(status), + }, + future::Either::Right((None, _)) => { + panic!("child process stream ended without an exit status") + } + } + }; + + render.did_execute(task_id, &command_line, &result, step, num_steps); + }) + .detach(); + + Ok(()) + } + fn execute_recipe_delete_command( &self, task_id: TaskId, @@ -962,6 +989,7 @@ impl<'a> Inner<'a> { self: &Arc, task_id: TaskId, spec: TaskSpec<'a>, + cancel: &cancel::Receiver, dep_chain: DepChain<'_>, ) -> Result { let dep_chain_entry = dep_chain.push(task_id); @@ -969,11 +997,11 @@ impl<'a> Inner<'a> { match spec { TaskSpec::Recipe(recipe) => match recipe { ir::RecipeMatch::Task(recipe) => { - self.execute_task_recipe(task_id, recipe, dep_chain_entry) + self.execute_task_recipe(task_id, recipe, cancel, dep_chain_entry) .await } ir::RecipeMatch::Build(recipe_match) => { - self.execute_build_recipe(task_id, recipe_match, dep_chain_entry) + self.execute_build_recipe(task_id, recipe_match, cancel, dep_chain_entry) .await } }, @@ -982,161 +1010,3 @@ impl<'a> Inner<'a> { } } } - -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum RunCommand { - Shell(ShellCommandLine), - Write(Absolute, Vec), - // We don't know yet if the source file is in the workspace or output - // directory, so we will resolve the path when running it. - Copy(Absolute, Absolute), - Info(DiagnosticSpan, String), - Warn(DiagnosticSpan, String), - // Path is always in the output directory. They don't need to exist. - Delete(DiagnosticSpan, Vec>), - Touch(DiagnosticSpan, Vec>), - SetCapture(bool), - SetEnv(String, String), - RemoveEnv(String), -} - -impl std::fmt::Display for RunCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RunCommand::Shell(shell_command_line) => shell_command_line.fmt(f), - RunCommand::Write(path_buf, vec) => { - write!(f, "write {} ({} bytes)", path_buf.display(), vec.len()) - } - RunCommand::Copy(from, to) => { - write!(f, "copy '{}' to '{}'", from, to.display()) - } - RunCommand::Info(_, message) => { - write!(f, "info \"{}\"", message.escape_default()) - } - RunCommand::Warn(_, message) => { - write!(f, "warn \"{}\"", message.escape_default()) - } - RunCommand::Delete(_, paths) => { - write!(f, "delete ")?; - if paths.len() == 1 { - write!(f, "{}", paths[0].display()) - } else { - write!(f, "[")?; - for (i, p) in paths.iter().enumerate() { - if i != 0 { - write!(f, ", ")?; - } - write!(f, "{}", p.display())?; - } - write!(f, "]") - } - } - RunCommand::Touch(_, paths) => { - write!(f, "touch ")?; - if paths.len() == 1 { - write!(f, "{}", paths[0].display()) - } else { - write!(f, "[")?; - for (i, p) in paths.iter().enumerate() { - if i != 0 { - write!(f, ", ")?; - } - write!(f, "{}", p.display())?; - } - write!(f, "]") - } - } - RunCommand::SetCapture(value) => write!(f, "set_capture = {value}"), - RunCommand::SetEnv(key, value) => write!(f, "env {key} = {value}"), - RunCommand::RemoveEnv(key) => write!(f, "env-remove {key}"), - } - } -} - -#[derive(Debug, Clone, Copy)] -struct DepChainEntry<'a> { - parent: DepChain<'a>, - this: TaskId, -} - -#[derive(Debug, Clone, Copy)] -enum DepChain<'a> { - Empty, - Owned(&'a OwnedDependencyChain), - Ref(&'a DepChainEntry<'a>), -} - -impl<'a> DepChain<'a> { - fn collect_vec(&self) -> Vec { - match self { - DepChain::Empty => Vec::new(), - DepChain::Owned(owned) => owned.vec.clone(), - DepChain::Ref(parent) => parent.collect_vec(), - } - } - - pub fn contains(&self, task: TaskId) -> bool { - match self { - DepChain::Empty => false, - DepChain::Owned(owned) => owned.vec.contains(&task), - DepChain::Ref(parent) => parent.contains(task), - } - } - - pub fn push<'b>(self, task: TaskId) -> DepChainEntry<'b> - where - 'a: 'b, - { - DepChainEntry { - parent: self, - this: task, - } - } -} - -impl DepChainEntry<'_> { - fn collect(&self) -> OwnedDependencyChain { - OwnedDependencyChain { - vec: self.collect_vec(), - } - } - - fn collect_vec(&self) -> Vec { - let mut vec = self.parent.collect_vec(); - vec.push(self.this); - vec - } - - fn contains(&self, task_id: TaskId) -> bool { - if self.this == task_id { - true - } else { - self.parent.contains(task_id) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OwnedDependencyChain { - vec: Vec, -} - -impl OwnedDependencyChain { - #[inline] - #[must_use] - pub fn into_inner(self) -> Vec { - self.vec - } -} - -impl std::fmt::Display for OwnedDependencyChain { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for (i, task_id) in self.vec.iter().enumerate() { - if i > 0 { - write!(f, " -> ")?; - } - write!(f, "{task_id}")?; - } - Ok(()) - } -} diff --git a/werk-runner/runner/command.rs b/werk-runner/runner/command.rs new file mode 100644 index 00000000..26ceaf92 --- /dev/null +++ b/werk-runner/runner/command.rs @@ -0,0 +1,76 @@ +use werk_fs::Absolute; +use werk_util::DiagnosticSpan; + +use crate::ShellCommandLine; + +#[derive(Debug, Clone, PartialEq)] +pub enum RunCommand { + Shell(ShellCommandLine), + Spawn(ShellCommandLine), + Write(Absolute, Vec), + // We don't know yet if the source file is in the workspace or output + // directory, so we will resolve the path when running it. + Copy(Absolute, Absolute), + Info(DiagnosticSpan, String), + Warn(DiagnosticSpan, String), + // Path is always in the output directory. They don't need to exist. + Delete(DiagnosticSpan, Vec>), + Touch(DiagnosticSpan, Vec>), + SetCapture(bool), + SetEnv(String, String), + RemoveEnv(String), +} + +impl std::fmt::Display for RunCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunCommand::Shell(shell_command_line) => shell_command_line.fmt(f), + RunCommand::Spawn(shell_command_line) => write!(f, "spawn {shell_command_line}"), + RunCommand::Write(path_buf, vec) => { + write!(f, "write {} ({} bytes)", path_buf.display(), vec.len()) + } + RunCommand::Copy(from, to) => { + write!(f, "copy '{}' to '{}'", from, to.display()) + } + RunCommand::Info(_, message) => { + write!(f, "info \"{}\"", message.escape_default()) + } + RunCommand::Warn(_, message) => { + write!(f, "warn \"{}\"", message.escape_default()) + } + RunCommand::Delete(_, paths) => { + write!(f, "delete ")?; + if paths.len() == 1 { + write!(f, "{}", paths[0].display()) + } else { + write!(f, "[")?; + for (i, p) in paths.iter().enumerate() { + if i != 0 { + write!(f, ", ")?; + } + write!(f, "{}", p.display())?; + } + write!(f, "]") + } + } + RunCommand::Touch(_, paths) => { + write!(f, "touch ")?; + if paths.len() == 1 { + write!(f, "{}", paths[0].display()) + } else { + write!(f, "[")?; + for (i, p) in paths.iter().enumerate() { + if i != 0 { + write!(f, ", ")?; + } + write!(f, "{}", p.display())?; + } + write!(f, "]") + } + } + RunCommand::SetCapture(value) => write!(f, "set_capture = {value}"), + RunCommand::SetEnv(key, value) => write!(f, "env {key} = {value}"), + RunCommand::RemoveEnv(key) => write!(f, "env-remove {key}"), + } + } +} diff --git a/werk-runner/runner/dep_chain.rs b/werk-runner/runner/dep_chain.rs new file mode 100644 index 00000000..659da8b9 --- /dev/null +++ b/werk-runner/runner/dep_chain.rs @@ -0,0 +1,94 @@ +use super::TaskId; + +#[derive(Debug, Clone, Copy)] +pub struct DepChainEntry<'a> { + parent: DepChain<'a>, + this: TaskId, +} + +#[derive(Debug, Clone, Copy)] +pub enum DepChain<'a> { + Empty, + Owned(&'a OwnedDependencyChain), + Ref(&'a DepChainEntry<'a>), +} + +impl<'a> DepChain<'a> { + fn collect_vec(&self) -> Vec { + match self { + DepChain::Empty => Vec::new(), + DepChain::Owned(owned) => owned.vec.clone(), + DepChain::Ref(parent) => parent.collect_vec(), + } + } + + #[must_use] + pub fn contains(&self, task: TaskId) -> bool { + match self { + DepChain::Empty => false, + DepChain::Owned(owned) => owned.vec.contains(&task), + DepChain::Ref(parent) => parent.contains(task), + } + } + + #[must_use] + pub fn push<'b>(self, task: TaskId) -> DepChainEntry<'b> + where + 'a: 'b, + { + DepChainEntry { + parent: self, + this: task, + } + } +} + +impl DepChainEntry<'_> { + #[must_use] + pub fn collect(&self) -> OwnedDependencyChain { + OwnedDependencyChain { + vec: self.collect_vec(), + } + } + + #[must_use] + pub fn collect_vec(&self) -> Vec { + let mut vec = self.parent.collect_vec(); + vec.push(self.this); + vec + } + + #[must_use] + pub fn contains(&self, task_id: TaskId) -> bool { + if self.this == task_id { + true + } else { + self.parent.contains(task_id) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedDependencyChain { + vec: Vec, +} + +impl OwnedDependencyChain { + #[inline] + #[must_use] + pub fn into_inner(self) -> Vec { + self.vec + } +} + +impl std::fmt::Display for OwnedDependencyChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, task_id) in self.vec.iter().enumerate() { + if i > 0 { + write!(f, " -> ")?; + } + write!(f, "{task_id}")?; + } + Ok(()) + } +} diff --git a/werk-runner/runner/task.rs b/werk-runner/runner/task.rs new file mode 100644 index 00000000..f555b666 --- /dev/null +++ b/werk-runner/runner/task.rs @@ -0,0 +1,127 @@ +use futures::channel::oneshot; +use werk_fs::{Absolute, SymPath}; +use werk_util::Symbol; + +use crate::{Error, ir}; + +use super::BuildStatus; + +pub enum TaskStatus { + Built(Result), + Pending(Vec>>), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TaskId { + Task(Symbol), + // TODO: When recipes can build multiple files, this needs to change to some + // ID that encapsulates the "recipe instance" rather than the path of a + // single target. + Build(Absolute), +} + +impl TaskId { + pub fn command(s: impl Into) -> Self { + let name = s.into(); + debug_assert!(!name.as_str().starts_with('/')); + TaskId::Task(name) + } + + pub fn build(p: impl AsRef>) -> Self { + TaskId::Build(Absolute::symbolicate(p)) + } + + pub fn try_build

(p: P) -> Result + where + P: TryInto>, + { + let path = p.try_into()?; + Ok(TaskId::build(path)) + } + + #[inline] + #[must_use] + pub fn is_command(&self) -> bool { + matches!(self, TaskId::Task(_)) + } + + #[inline] + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + TaskId::Task(task) => task.as_str(), + TaskId::Build(build) => build.as_inner().as_str(), + } + } + + #[inline] + #[must_use] + pub fn as_path(&self) -> Option<&Absolute> { + if let TaskId::Build(build) = self { + Some(build.as_path()) + } else { + None + } + } + + #[inline] + #[must_use] + pub fn short_name(&self) -> &'static str { + match self { + TaskId::Task(task) => task.as_str(), + TaskId::Build(path) => { + let Some((_prefix, filename)) = path + .as_inner() + .as_str() + .rsplit_once(werk_fs::Path::SEPARATOR) + else { + // The path is absolute. + unreachable!() + }; + filename + } + } + } +} + +impl std::fmt::Display for TaskId { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +pub enum TaskSpec<'a> { + Recipe(ir::RecipeMatch<'a>), + CheckExists(Absolute), + /// Check if the file exists, but don't emit an error if it doesn't. This + /// applies to dependencies discovered through depfiles, where the depfile + /// may be outdated (from a previous build). + /// + /// If the file does not exist, the task will be considered outdated. + CheckExistsRelaxed(Absolute), +} + +impl TaskSpec<'_> { + #[must_use] + pub fn to_task_id(&self) -> TaskId { + match self { + TaskSpec::Recipe(ir::RecipeMatch::Build(build_recipe_match)) => { + TaskId::build(build_recipe_match.target_file.clone()) + } + TaskSpec::Recipe(ir::RecipeMatch::Task(task_recipe_match)) => { + TaskId::command(task_recipe_match.name) + } + TaskSpec::CheckExists(path_buf) | TaskSpec::CheckExistsRelaxed(path_buf) => { + TaskId::build(path_buf.clone().into_boxed_path()) + } + } + } +} + +pub enum DepfileSpec<'a> { + /// The depfile is explicitly generated by a recipe. + Recipe(ir::BuildRecipeMatch<'a>), + /// The depfile is implicitly generated by the associated command recipe. + ImplicitlyGenerated(Absolute), +} diff --git a/werk-runner/warning.rs b/werk-runner/warning.rs index 6c1f8f80..4cf48f88 100644 --- a/werk-runner/warning.rs +++ b/werk-runner/warning.rs @@ -23,6 +23,8 @@ pub enum Warning { UnusedDefine(String), #[error("output directory changed; was `{0}`, is now `{1}`")] OutputDirectoryChanged(Absolute, Absolute), + #[error("one or more child processes did not stop when asked, and may be left as zombies")] + ZombieChild, } impl werk_util::AsDiagnostic for Warning { @@ -87,7 +89,8 @@ impl werk_util::AsDiagnostic for Warning { .footer(format_args!("no `config` statement exists with the name `{key}`")) .footer("maybe a `let` statement should be changed to a `config` statement?"), Warning::OutputDirectoryChanged(..) => level - .diagnostic("W1001") + .diagnostic("W1001"), + Warning::ZombieChild => level.diagnostic("W1002"), }.title(self) // Use Display impl from thiserror } } diff --git a/werk-util/cancel.rs b/werk-util/cancel.rs new file mode 100644 index 00000000..66c9b243 --- /dev/null +++ b/werk-util/cancel.rs @@ -0,0 +1,114 @@ +//! Cancellation signal + +use std::{ + pin::Pin, + sync::{Arc, atomic::AtomicBool}, + task::{Context, Poll, Waker}, +}; + +use parking_lot::Mutex; + +pub struct Sender { + state: Arc, +} + +#[derive(Clone)] +pub struct Receiver { + state: Arc, +} + +struct State { + cancelled: AtomicBool, + wakers: Mutex>, +} + +impl Sender { + #[must_use] + #[inline] + pub fn new() -> Self { + Sender { + state: Arc::new(State { + cancelled: AtomicBool::new(false), + wakers: Mutex::new(Vec::new()), + }), + } + } + + #[inline] + #[must_use] + pub fn receiver(&self) -> Receiver { + Receiver { + state: self.state.clone(), + } + } + + #[inline] + pub fn forget(self) { + // Drop the sender, but keep the receiver alive. + std::mem::forget(self); + } + + #[inline] + pub fn cancel(&self) { + if self + .state + .cancelled + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + return; + } + + let mut wakers = self.state.wakers.lock(); + for waker in wakers.drain(..) { + waker.wake(); + } + } +} + +impl Default for Sender { + #[inline] + fn default() -> Self { + Self::new() + } +} + +impl Drop for Sender { + fn drop(&mut self) { + // Cancel the receiver if the sender is dropped. + self.cancel(); + } +} + +impl Receiver { + fn poll_pinned(&self, cx: &mut Context<'_>) -> Poll<()> { + if self + .state + .cancelled + .load(std::sync::atomic::Ordering::SeqCst) + { + return Poll::Ready(()); + } + + let mut wakers = self.state.wakers.lock(); + wakers.push(cx.waker().clone()); + Poll::Pending + } +} + +impl Future for Receiver { + type Output = (); + + #[inline] + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.poll_pinned(cx) + } +} + +impl Future for &Receiver { + type Output = (); + + #[inline] + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.poll_pinned(cx) + } +} diff --git a/werk-util/lib.rs b/werk-util/lib.rs index d1bfeb63..f3290d1e 100644 --- a/werk-util/lib.rs +++ b/werk-util/lib.rs @@ -1,3 +1,4 @@ +pub mod cancel; mod diagnostic; mod os_str; mod semantic_hash; diff --git a/werk-vscode/syntaxes/werk.tmLanguage.json b/werk-vscode/syntaxes/werk.tmLanguage.json index 0f70e05f..bdbf36c0 100644 --- a/werk-vscode/syntaxes/werk.tmLanguage.json +++ b/werk-vscode/syntaxes/werk.tmLanguage.json @@ -26,7 +26,7 @@ "patterns": [ { "name": "keyword.control.werk", - "match": "\\b(default|config|let|build|task|from|to|depfile|run|include)\\b" + "match": "\\b(default|config|let|build|task|from|to|depfile|run|spawn|include)\\b" } ] },