diff --git a/Cargo.lock b/Cargo.lock index 235584e52..e858a2335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6373,6 +6373,7 @@ dependencies = [ "tangram_session", "tangram_uri", "tangram_util", + "tempfile", "tokio", "tokio-stream", "tokio-util", @@ -6427,6 +6428,7 @@ dependencies = [ "tangram_serialize", "tangram_uri", "tangram_util", + "tempfile", "time", "tokio", "tokio-rustls", @@ -6620,11 +6622,14 @@ dependencies = [ "tangram_compiler", "tangram_either", "tangram_futures", + "tangram_util", "tangram_v8", + "tempfile", "tokio", "tokio-util", "toml", "v8", + "xattr", ] [[package]] diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index dc7643950..3f6827420 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -64,6 +64,7 @@ ratatui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +tempfile = { workspace = true } tangram_builtin = { workspace = true } tangram_client = { workspace = true } tangram_compiler = { workspace = true } diff --git a/packages/cli/src/build.rs b/packages/cli/src/build.rs index b14b99cf6..fe28702a9 100644 --- a/packages/cli/src/build.rs +++ b/packages/cli/src/build.rs @@ -81,8 +81,29 @@ impl Cli { sandbox, ..options.spawn }; + + // Get the reference. + let arg = tg::get::Arg { + checkin: spawn.checkin.clone().to_options(), + ..Default::default() + }; + let referent = self.get_reference_with_arg(&reference, arg, true).await?; + let item = referent + .item + .clone() + .left() + .ok_or_else(|| tg::error!("expected an object"))?; + let referent = referent.map(|_| item); + + // Spawn the process. let crate::process::spawn::Output { process, output } = self - .spawn(spawn, reference, trailing, None, None, None) + .spawn( + spawn, + reference, + referent, + trailing, + crate::process::spawn::Stdio::default(), + ) .boxed() .await?; diff --git a/packages/cli/src/process/spawn.rs b/packages/cli/src/process/spawn.rs index e566d5670..51b10efc6 100644 --- a/packages/cli/src/process/spawn.rs +++ b/packages/cli/src/process/spawn.rs @@ -192,16 +192,36 @@ pub struct Output { pub output: tg::process::spawn::Output, } +#[derive(Clone, Debug, Default)] +pub struct Stdio { + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, +} + impl Cli { pub async fn command_process_spawn(&mut self, args: Args) -> tg::Result<()> { + // Get the reference. + let arg = tg::get::Arg { + checkin: args.options.checkin.to_options(), + ..Default::default() + }; + let referent = self + .get_reference_with_arg(&args.reference, arg, true) + .await?; + let item = referent + .item + .clone() + .left() + .ok_or_else(|| tg::error!("expected an object"))?; + let referent = referent.map(|_| item); let Output { output, .. } = self .spawn( args.options, args.reference, + referent, args.trailing, - None, - None, - None, + Stdio::default(), ) .boxed() .await?; @@ -217,30 +237,21 @@ impl Cli { &mut self, options: Options, reference: tg::Reference, + mut referent: tg::Referent, trailing: Vec, - stdin: Option, - stdout: Option, - stderr: Option, + stdio: Stdio, ) -> tg::Result { + let Stdio { + stdin, + stdout, + stderr, + } = stdio; let handle = self.handle().await?; // Determine if the process is sandboxed. let sandbox = options.sandbox.get().unwrap_or_default() || options.remotes.remotes.is_some(); - // Get the reference. - let arg = tg::get::Arg { - checkin: options.checkin.to_options(), - ..Default::default() - }; - let referent = self.get_reference_with_arg(&reference, arg, true).await?; - let item = referent - .item - .clone() - .left() - .ok_or_else(|| tg::error!("expected an object"))?; - let mut referent = referent.map(|_| item); - // Create the command builder. let mut command_env = None; let mut command = match referent.item.clone() { diff --git a/packages/cli/src/run.rs b/packages/cli/src/run.rs index 287e9b805..6b1336f06 100644 --- a/packages/cli/src/run.rs +++ b/packages/cli/src/run.rs @@ -1,7 +1,11 @@ use { crate::Cli, futures::prelude::*, - std::{fmt::Write as _, path::PathBuf}, + num::ToPrimitive as _, + std::{ + collections::BTreeMap, fmt::Write as _, os::unix::process::ExitStatusExt as _, + path::PathBuf, + }, tangram_client::prelude::*, tangram_futures::task::Task, }; @@ -66,213 +70,441 @@ pub struct Options { impl Cli { pub async fn command_run(&mut self, args: Args) -> tg::Result<()> { - let handle = self.handle().await?; - let Args { - options, reference, + mut options, trailing, } = args; - // If the build flag is set, then build and get the output. + // Spawn a sandboxed run for builds. let reference = if options.build { - // Spawn the process. - let spawn = crate::process::spawn::Options { - sandbox: crate::process::spawn::Sandbox::new(Some(true)), - local: options.spawn.local.clone(), - remotes: options.spawn.remotes.clone(), + // Get the reference. + let arg = tg::get::Arg { + checkin: options.spawn.checkin.to_options(), ..Default::default() }; - let crate::process::spawn::Output { process, output } = self - .spawn(spawn, reference, vec![], None, None, None) - .boxed() - .await?; - - // Print the process. - if !self.args.quiet { - let mut message = process.item().id().to_string(); - if let Some(token) = process.item().token() { - write!(message, " {token}").unwrap(); - } - Self::print_info_message(&message); - } + let referent = self.get_reference_with_arg(&reference, arg, true).await?; + let item = referent + .item + .clone() + .left() + .ok_or_else(|| tg::error!("expected an object"))?; + let referent = referent.map(|_| item); + + let options = Options { + checkout_force: false, + checkout: None, + detach: false, + executable_path: None, + ..options.clone() + }; + let output = self + .run_sandboxed(&options, &reference, &referent, Vec::new()) + .await + .map_err(|source| tg::error!(!source, %reference, "failed to build"))? + .ok_or_else(|| tg::error!("expected an output"))?; + let object = output + .try_unwrap_object() + .ok() + .ok_or_else(|| tg::error!("expected the build to output an object"))?; + let id = object.id(); + tg::Reference::with_object(id) + } else { + reference + }; + options.build = false; - // If the spawn output includes a wait output, then use it. - let wait = output - .wait - .map(TryInto::try_into) - .transpose() - .map_err(|source| tg::error!(!source, "failed to parse the wait output"))?; + // Get the reference. + let arg = tg::get::Arg { + checkin: options.spawn.checkin.to_options(), + ..Default::default() + }; + let referent = self.get_reference_with_arg(&reference, arg, true).await?; + let item = referent + .item + .clone() + .left() + .ok_or_else(|| tg::error!("expected an object"))?; + let referent = referent.map(|_| item); + + // Run the process. + let output = if Self::needs_sandbox(&options) { + self.run_sandboxed(&options, &reference, &referent, trailing) + .await? + } else { + self.run_unsandboxed(&options, &reference, &referent, trailing) + .await? + }; - // If the process is not finished, then wait for it to finish while showing the viewer if enabled. - let wait = if let Some(wait) = wait { - wait + // Check out the output if requested. + if let Some(path) = options.checkout { + let handle = self.handle().await?; + let output = output + .filter(|v| !v.is_null()) + .ok_or_else(|| tg::error!("expected an output"))?; + + // Get the artifact. + let artifact: tg::Artifact = output + .clone() + .try_into() + .map_err(|_| tg::error!("expected an artifact"))?; + + // Get the path. + let path = if let Some(path) = path { + let path = tangram_util::fs::canonicalize_parent(path) + .await + .map_err(|source| tg::error!(!source, "failed to canonicalize the path"))?; + Some(path) } else { - // Spawn the view task. - let view_task = { - let handle = handle.clone(); - let root = process.clone().map(crate::viewer::Item::Process); - let task = Task::spawn_blocking(move |stop| -> tg::Result<()> { - let local_set = tokio::task::LocalSet::new(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|source| { - tg::error!(!source, "failed to create the tokio runtime") - })?; - local_set.block_on(&runtime, async move { - let viewer_options = crate::viewer::Options { - collapse_process_children: true, - depth: None, - expand_objects: false, - expand_packages: false, - expand_processes: true, - expand_metadata: false, - expand_tags: false, - expand_values: false, - show_process_commands: false, - }; - let mut viewer = - crate::viewer::Viewer::new(&handle, root, viewer_options); - match options.build_view { - crate::build::View::None => (), - crate::build::View::Inline => { - viewer.run_inline(stop, false).await?; - }, - crate::build::View::Fullscreen => { - viewer.run_fullscreen(stop).await?; - }, - } - Ok::<_, tg::Error>(()) - }) - }); - Some(task) - }; + None + }; - // Spawn a task to attempt to cancel the process on the first interrupt signal and exit the process on the second. - let cancel_task = tokio::spawn({ - let handle = handle.clone(); - let process = process.clone(); - async move { - tokio::signal::ctrl_c().await.unwrap(); - tokio::spawn(async move { - process - .item() - .cancel(&handle) - .await - .inspect_err(|error| { - tracing::error!(?error, "failed to cancel the process"); - }) - .ok(); - }); - tokio::signal::ctrl_c().await.unwrap(); - std::process::exit(130); - } - }); + // Check out the artifact. + let artifact = artifact.id(); + let arg = tg::checkout::Arg { + artifact: artifact.clone(), + dependencies: path.is_some(), + extension: None, + force: options.checkout_force, + lock: None, + path, + }; + let stream = handle.checkout(arg).await.map_err( + |source| tg::error!(!source, %artifact, "failed to check out the artifact"), + )?; + let tg::checkout::Output { path, .. } = + self.render_progress_stream(stream).await.map_err( + |source| tg::error!(!source, %artifact, "failed to check out the artifact"), + )?; - // Await the process. - let arg = tg::process::wait::Arg { - token: process.item().token().cloned(), - ..tg::process::wait::Arg::default() - }; - let result = process.item().wait(&handle, arg).await; + // Print the path. + Self::print_display(path.display()); - // Abort the cancel task. - cancel_task.abort(); + return Ok(()); + } - // Stop and await the view task. - if let Some(view_task) = view_task { - view_task.stop(); - match view_task.wait().await { - Ok(Ok(())) => {}, - Ok(Err(error)) => { - tracing::warn!(?error, "failed to render the process viewer"); - Self::print_warning_message("failed to render the process viewer"); + // Print the output. + if !options.verbose + && let Some(output) = output + && !output.is_null() + { + let arg = tg::object::get::Arg { + local: options.spawn.local.local, + metadata: false, + remotes: options.spawn.remotes.remotes, + }; + self.print_value(&output, options.print, arg).await?; + } + Ok(()) + } + + async fn run_unsandboxed( + &mut self, + _options: &Options, + _reference: &tg::Reference, + referent: &tg::Referent, + trailing: Vec, + ) -> tg::Result> { + let handle = self.handle().await?; + + // Create a temp directory for the output. + let temp = tempfile::tempdir() + .map_err(|source| tg::error!(!source, "failed to create a temp directory"))?; + let output_dir = temp.path().join("output"); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|source| tg::error!(!source, "failed to create the output directory"))?; + let process_id = referent.item().id().to_string(); + let output_path = output_dir.join(&process_id); + + // Set the artifacts path for rendering. + let artifacts_path = self.directory_path().join("artifacts"); + tg::run::set_artifacts_path(&artifacts_path); + + // Inherit the process env. + let mut env: BTreeMap = std::env::vars().collect(); + env.remove("TANGRAM_OUTPUT"); + env.remove("TANGRAM_PROCESS"); + env.remove("TANGRAM_URL"); + + // Get the server URL. + let url = match &handle { + tg::Either::Left(client) => client.url().to_string(), + tg::Either::Right(server) => server + .url() + .ok_or_else(|| tg::error!("the server does not have a URL"))? + .to_string(), + }; + + // Create the command based on the referent item type. + let (executable, args, command_env, cwd) = match referent.item().clone() { + tg::Object::Command(command) => { + let data = command + .data(&handle) + .await + .map_err(|source| tg::error!(!source, "failed to get the command data"))?; + + // Cache the command's artifact children. + let artifacts: Vec = command + .children(&handle) + .await + .map_err(|source| tg::error!(!source, "failed to get the command's children"))? + .into_iter() + .filter_map(|object| object.id().try_into().ok()) + .collect(); + if !artifacts.is_empty() { + let arg = tg::cache::Arg { artifacts }; + let stream = handle + .cache(arg) + .await + .map_err(|source| tg::error!(!source, "failed to cache the artifacts"))?; + self.render_progress_stream(stream).await?; + } + + // Determine if this is a JS or builtin command. + let is_js = matches!(data.host.as_str(), "js" | "builtin"); + + // Resolve the executable and render the args. + let (executable, mut args) = if is_js { + let executable = tangram_util::env::current_exe().map_err(|source| { + tg::error!(!source, "failed to get the current executable") + })?; + let subcommand = if data.host == "builtin" { + "builtin" + } else { + "js" + }; + let args = vec![subcommand.to_owned(), data.executable.to_string()]; + (executable, args) + } else { + let executable = match &data.executable { + tg::command::data::Executable::Artifact(exe) => { + let mut path = artifacts_path.join(exe.artifact.to_string()); + if let Some(subpath) = &exe.path { + path.push(subpath); + } + path }, - Err(error) => { - tracing::warn!(?error, "failed to join the process viewer task"); - Self::print_warning_message("failed to render the process viewer"); + tg::command::data::Executable::Module(_) => { + return Err(tg::error!("invalid executable")); }, - } + tg::command::data::Executable::Path(exe) => exe.path.clone(), + }; + let args_values: Vec = data + .args + .iter() + .cloned() + .map(tg::Value::try_from_data) + .collect::>()?; + let args = tg::run::render_args(&args_values, &output_path)?; + (executable, args) + }; + + // Render the command env. + let env_values: tg::value::Map = data + .env + .iter() + .map(|(k, v)| { + Ok::<_, tg::Error>((k.clone(), tg::Value::try_from_data(v.clone())?)) + }) + .collect::>()?; + let command_env = tg::run::render_env(&env_values, &output_path)?; + + // Append trailing args. + args.extend(trailing); + + (executable, args, Some(command_env), data.cwd.clone()) + }, + + tg::Object::Directory(directory) => { + let executable = tangram_util::env::current_exe().map_err(|source| { + tg::error!(!source, "failed to get the current executable") + })?; + let id = directory.id(); + let mut args = vec!["js".to_owned(), id.to_string()]; + args.extend(trailing); + (executable, args, None, None) + }, + + tg::Object::File(file) => { + let kind = file + .module(&handle) + .await + .map_err(|source| tg::error!(!source, "failed to get the module kind"))?; + if kind.is_some() { + let tg_exe = tangram_util::env::current_exe().map_err(|source| { + tg::error!(!source, "failed to get the current executable") + })?; + let id = file.id(); + let mut args = vec!["js".to_owned(), id.to_string()]; + args.extend(trailing); + (tg_exe, args, None, None) + } else { + // Cache the file. + let artifact_id = file.id(); + let arg = tg::cache::Arg { + artifacts: vec![artifact_id.clone().into()], + }; + let stream = handle + .cache(arg) + .await + .map_err(|source| tg::error!(!source, "failed to cache the artifact"))?; + self.render_progress_stream(stream).await?; + let executable = artifacts_path.join(artifact_id.to_string()); + (executable, trailing, None, None) } + }, - result? - }; + tg::Object::Symlink(_) => { + return Err(tg::error!("unimplemented")); + }, - // Set the exit. - if wait.exit != 0 { - self.exit.replace(wait.exit); - } + _ => { + return Err(tg::error!("expected a command or an artifact")); + }, + }; - // Handle an error. - if let Some(error) = wait.error { - let error = error - .to_data_or_id() - .map_left(|data| { - Box::new(tg::error::Object::try_from_data(data).unwrap_or_else(|_| { - tg::error::Object { - message: Some("invalid error".to_owned()), - ..Default::default() - } - })) - }) - .map_right(|id| Box::new(tg::Error::with_id(id))); - let error = tg::Error::with_object(tg::error::Object { - message: Some("the process failed".to_owned()), - source: Some(process.clone().map(|_| error)), - values: [("id".to_owned(), process.item().id().to_string())].into(), + // Merge the command env on top of the process env. + if let Some(command_env) = command_env { + env.extend(command_env); + } + + // Create the tokio process command. + let mut cmd = tokio::process::Command::new(&executable); + cmd.args(&args) + .env_clear() + .envs(&env) + .env("TANGRAM_URL", &url) + .env("TANGRAM_OUTPUT", output_path.to_str().unwrap()) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + + // Spawn the process. + let mut child = cmd + .spawn() + .map_err(|source| tg::error!(!source, "failed to spawn the process"))?; + + // Wait for the process to exit. + let status = child + .wait() + .await + .map_err(|source| tg::error!(!source, "failed to wait for the process"))?; + let exit = None + .or(status.code()) + .or(status.signal().map(|signal| 128 + signal)) + .unwrap() + .to_u8() + .unwrap(); + + // Check for output. + let mut output = None; + let mut error = None; + + // Try to read the user.tangram.output xattr. + if let Ok(Some(bytes)) = xattr::get(&output_path, "user.tangram.output") { + let tgon = String::from_utf8(bytes) + .map_err(|source| tg::error!(!source, "failed to decode the output xattr"))?; + output = Some( + tgon.parse::() + .map_err(|source| tg::error!(!source, "failed to parse the output xattr"))?, + ); + } + + // Try to read the user.tangram.error xattr. + if let Ok(Some(bytes)) = xattr::get(&output_path, "user.tangram.error") { + let data = serde_json::from_slice::(&bytes) + .map_err(|source| tg::error!(!source, "failed to deserialize the error xattr"))?; + error = Some( + tg::Error::try_from(data) + .map_err(|source| tg::error!(!source, "failed to convert the error data"))?, + ); + } + + // If no xattr output was set but the output path exists, check it in destructively. + let exists = tokio::fs::try_exists(&output_path) + .await + .map_err(|source| tg::error!(!source, "failed to check if the output path exists"))?; + if output.is_none() && exists { + let arg = tg::checkin::Arg { + options: tg::checkin::Options { + destructive: true, + deterministic: true, + ignore: false, + lock: None, + locked: true, + root: true, ..Default::default() - }); - return Err(error); - } + }, + path: output_path.clone(), + updates: Vec::new(), + }; + let stream = handle + .checkin(arg) + .await + .map_err(|source| tg::error!(!source, "failed to check in the output"))?; + let checkin_output = self + .render_progress_stream(stream) + .await + .map_err(|source| tg::error!(!source, "failed to check in the output"))?; + output = Some(tg::Artifact::with_id(checkin_output.artifact.item).into()); + } - // Handle non-zero exit. - if wait.exit > 1 && wait.exit < 128 { - return Err(tg::error!("the process exited with code {}", wait.exit)); - } - if wait.exit >= 128 { - return Err(tg::error!( - "the process exited with signal {}", - wait.exit - 128 - )); - } + // Set the exit. + if exit != 0 { + self.exit.replace(exit); + } - // Get the output. - let output = wait.output.unwrap_or(tg::Value::Null); + // Handle an error. + if let Some(error) = error { + return Err(tg::error!(source = error, "the process failed")); + } - let object = output - .try_unwrap_object() - .ok() - .ok_or_else(|| tg::error!("expected the build to output an object"))?; - let id = object.id(); - tg::Reference::with_object(id) - } else { - reference - }; + // Handle non-zero exit. + if exit > 1 && exit < 128 { + return Err(tg::error!("the process exited with code {}", exit)); + } + if exit >= 128 { + return Err(tg::error!("the process exited with signal {}", exit - 128)); + } + + Ok(output) + } + + async fn run_sandboxed( + &mut self, + options: &Options, + reference: &tg::Reference, + referent: &tg::Referent, + trailing: Vec, + ) -> tg::Result> { + let handle = self.handle().await?; // Handle the executable path. - let reference = if let Some(path) = &options.executable_path { - let referent = self.get_reference(&reference).await?; + let referent = if let Some(executable_path) = &options.executable_path { let directory = referent - .item - .left() - .ok_or_else(|| tg::error!("expected an object"))? - .try_unwrap_directory() + .item() + .try_unwrap_directory_ref() .ok() .ok_or_else(|| tg::error!("expected a directory"))?; - let artifact = directory.get(&handle, path).await.map_err( - |source| tg::error!(!source, path = %path.display(), "failed to get the artifact"), + let artifact = directory.get(&handle, executable_path).await.map_err( + |source| tg::error!(!source, path = %executable_path.display(), "failed to get the artifact"), )?; let id = artifact .store(&handle) .await .map_err(|source| tg::error!(!source, "failed to store the artifact"))?; - tg::Reference::with_object(id.into()) + let mut referent = referent.clone().map(|_| tg::Object::with_id(id.into())); + referent.options.path = Some(executable_path.clone()); + referent } else { - reference + referent.clone() }; - // Get the remote. + // Get the remote let remote = options .spawn .remotes @@ -280,35 +512,44 @@ impl Cli { .clone() .and_then(|remotes| remotes.into_iter().next()); - // Create the stdio. - let stdio = stdio::Stdio::new(&handle, remote.clone(), &options) - .await - .map_err(|source| tg::error!(!source, "failed to create stdio"))?; - - let local = options.spawn.local.local; - let remotes = options.spawn.remotes.remotes.clone(); + // Create the stdio if this is not a build. + let stdio = if options.build { + None + } else { + let stdio = stdio::Stdio::new(&handle, remote.clone(), options) + .await + .map_err(|source| tg::error!(!source, "failed to create stdio"))?; + Some(stdio) + }; // Spawn the process. + let spawn = crate::process::spawn::Options { + sandbox: crate::process::spawn::Sandbox::new(Some(true)), + local: options.spawn.local.clone(), + remotes: options.spawn.remotes.clone(), + ..Default::default() + }; + let process_stdio = stdio + .as_ref() + .map(|stdio| crate::process::spawn::Stdio { + stdin: stdio.stdin.clone(), + stdout: stdio.stdout.clone(), + stderr: stdio.stderr.clone(), + }) + .unwrap_or_default(); let crate::process::spawn::Output { process, output } = self - .spawn( - options.spawn, - reference, - trailing, - stdio.stdin.clone(), - stdio.stdout.clone(), - stdio.stderr.clone(), - ) + .spawn(spawn, reference.clone(), referent, trailing, process_stdio) .boxed() .await?; // If the detach flag is set, then print the process ID and return. if options.detach { if options.verbose { - self.print_serde(output, options.print).await?; + self.print_serde(output, options.print.clone()).await?; } else { Self::print_display(&output.process); } - return Ok(()); + return Ok(None); } // Print the process. @@ -321,74 +562,168 @@ impl Cli { } // Enable raw mode if necessary. - if let Some(tty) = &stdio.tty { + if let Some(stdio) = &stdio + && let Some(tty) = &stdio.tty + { tty.enable_raw_mode()?; } - // Spawn the stdio task. - let stdio_task = Task::spawn({ - let handle = handle.clone(); - let stdio = stdio.clone(); - |stop| async move { self::stdio::task(&handle, stop, stdio).boxed().await } - }); - - // Spawn signal task. - let signal_task = tokio::spawn({ - let handle = handle.clone(); - let process = process.item().id().clone(); - let remote = remote.clone(); - async move { - self::signal::task(&handle, &process, remote).await.ok(); - } - }); - - // Await the process unless the spawn output already includes the wait output. - let result = if let Some(wait) = output + // If the spawn output includes a wait output, then use it. + let wait = output .wait .map(TryInto::try_into) .transpose() - .map_err(|source| tg::error!(!source, "failed to parse the wait output"))? - { - Ok(wait) + .map_err(|source| tg::error!(!source, "failed to parse the wait output"))?; + + // If the process is not finished, then wait for it to finish while showing the viewer if enabled. + let wait = if let Some(wait) = wait { + wait } else { + // Spawn the stdio task. + let stdio_task = stdio.clone().map(|stdio| { + Task::spawn({ + let handle = handle.clone(); + |stop| async move { self::stdio::task(&handle, stop, stdio).boxed().await } + }) + }); + + // Spawn signal task. This will be handled by the cancellation tasks for builds. + let signal_task = if options.build { + None + } else { + Some(tokio::spawn({ + let handle = handle.clone(); + let process = process.item().id().clone(); + let remote = remote.clone(); + async move { + self::signal::task(&handle, &process, remote).await.ok(); + } + })) + }; + + // Spawn the view task, if this is a build. + let view_task = if options.build { + let handle = handle.clone(); + let root = process.clone().map(crate::viewer::Item::Process); + let build_view = options.build_view; + let task = Task::spawn_blocking(move |stop| -> tg::Result<()> { + let local_set = tokio::task::LocalSet::new(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|source| { + tg::error!(!source, "failed to create the tokio runtime") + })?; + local_set.block_on(&runtime, async move { + let viewer_options = crate::viewer::Options { + collapse_process_children: true, + depth: None, + expand_objects: false, + expand_packages: false, + expand_processes: true, + expand_metadata: false, + expand_tags: false, + expand_values: false, + show_process_commands: false, + }; + let mut viewer = crate::viewer::Viewer::new(&handle, root, viewer_options); + match build_view { + crate::build::View::None => (), + crate::build::View::Inline => { + viewer.run_inline(stop, false).await?; + }, + crate::build::View::Fullscreen => { + viewer.run_fullscreen(stop).await?; + }, + } + Ok::<_, tg::Error>(()) + }) + }); + Some(task) + } else { + None + }; + + // Spawn a task to attempt to cancel the process on the first interrupt signal and exit the process on the second. + let cancel_task = if options.build { + Some(tokio::spawn({ + let handle = handle.clone(); + let process = process.clone(); + async move { + tokio::signal::ctrl_c().await.unwrap(); + tokio::spawn(async move { + process + .item() + .cancel(&handle) + .await + .inspect_err(|error| { + tracing::error!(?error, "failed to cancel the process"); + }) + .ok(); + }); + tokio::signal::ctrl_c().await.unwrap(); + std::process::exit(130); + } + })) + } else { + None + }; + + // Await the process. let arg = tg::process::wait::Arg { token: process.item().token().cloned(), ..tg::process::wait::Arg::default() }; - process - .item() - .wait(&handle, arg) - .await - .map_err(|source| tg::error!(!source, "failed to await the process")) - }; - - // Close stdout and stderr. - stdio.close(&handle).await?; - - // Stop and await the stdio task. - stdio_task.stop(); + let result = process.item().wait(&handle, arg).await; + + // Close stdio. + if let Some(stdio) = stdio { + stdio.close(&handle).await?; + if let Some(task) = stdio_task { + task.stop(); + task.wait().await.unwrap()?; + } + stdio.delete(&handle).await?; + } - // Await the stdio task. - stdio_task.wait().await.unwrap()?; + // Abort the signal task. + if let Some(signal_task) = signal_task { + signal_task.abort(); + } - // Delete stdio. - stdio.delete(&handle).await?; + // Abort the cancel task. + if let Some(cancel_task) = cancel_task { + cancel_task.abort(); + } - // Abort the signal task. - signal_task.abort(); + // Stop and await the view task. + if let Some(view_task) = view_task { + view_task.stop(); + match view_task.wait().await { + Ok(Ok(())) => {}, + Ok(Err(error)) => { + tracing::warn!(?error, "failed to render the process viewer"); + Self::print_warning_message("failed to render the process viewer"); + }, + Err(error) => { + tracing::warn!(?error, "failed to join the process viewer task"); + Self::print_warning_message("failed to render the process viewer"); + }, + } + } - // Handle the result. - let wait = result.map_err(|source| tg::error!(!source, "failed to await the process"))?; + result.map_err(|error| tg::error!(!error, "failed to await the process"))? + }; - // Print verbose output if requested. - if options.verbose { + // Print verbose output if requested and this is not a pre-run build. + if options.verbose && !options.build { let output = tg::process::wait::Output { error: wait.error.as_ref().map(tg::Error::to_data_or_id), exit: wait.exit, output: wait.output.as_ref().map(tg::Value::to_data), }; self.print_serde(output, options.print.clone()).await?; - return Ok(()); + return Ok(None); } // Set the exit. @@ -417,6 +752,7 @@ impl Cli { }); return Err(error); } + // Handle non-zero exit. if wait.exit > 1 && wait.exit < 128 { return Err(tg::error!("the process exited with code {}", wait.exit)); @@ -428,61 +764,50 @@ impl Cli { )); } - // Get the output. - let output = wait.output.unwrap_or(tg::Value::Null); + Ok(wait.output) + } - // Check out the output if requested. - if let Some(path) = options.checkout { - // Get the artifact. - let artifact: tg::Artifact = output - .clone() - .try_into() - .map_err(|_| tg::error!("expected an artifact"))?; + fn needs_sandbox(options: &Options) -> bool { + // Sandbox if explicitly requested. + if options.spawn.sandbox.get().is_some_and(|sbx| sbx) { + return true; + } - // Get the path. - let path = if let Some(path) = path { - let path = tangram_util::fs::canonicalize_parent(path) - .await - .map_err(|source| tg::error!(!source, "failed to canonicalize the path"))?; - Some(path) - } else { - None - }; + // Remote processes imply sandboxing. + if options + .spawn + .remotes + .remotes + .as_ref() + .is_some_and(|remotes| !remotes.is_empty()) + { + return true; + } - // Check out the artifact. - let artifact = artifact.id(); - let arg = tg::checkout::Arg { - artifact: artifact.clone(), - dependencies: path.is_some(), - extension: None, - force: options.checkout_force, - lock: None, - path, - }; - let stream = handle.checkout(arg).await.map_err( - |source| tg::error!(!source, %artifact, "failed to check out the artifact"), - )?; - let tg::checkout::Output { path, .. } = - self.render_progress_stream(stream).await.map_err( - |source| tg::error!(!source, %artifact, "failed to check out the artifact"), - )?; + // Detached processes are currently sandboxed. This could change? + if options.detach { + return true; + } - // Print the path. - self.print_serde(path, options.print).await?; + // Cached processes must have been sandboxed. + if options.spawn.cached.is_some_and(|cached| cached) { + return true; + } - return Ok(()); + // Processes with a checksum should also run in a sandbox, to ensure cache hits. + if options.spawn.checksum.is_some() { + return true; } - // Print the output. - if !options.verbose && !output.is_null() { - let arg = tg::object::get::Arg { - local, - metadata: false, - remotes, - }; - self.print_value(&output, options.print, arg).await?; + // You need a sandbox to deny network access. + if options.spawn.network.is_none_or(|network| !network) { + return true; } - Ok(()) + if !options.spawn.mounts.is_empty() { + return true; + } + + false } } diff --git a/packages/cli/src/shell/common.rs b/packages/cli/src/shell/common.rs index 08a99ac46..21c4df42f 100644 --- a/packages/cli/src/shell/common.rs +++ b/packages/cli/src/shell/common.rs @@ -295,8 +295,21 @@ impl Cli { sandbox: crate::process::spawn::Sandbox::new(Some(true)), ..Default::default() }; + let referent = self.get_reference(reference).await?; + let item = referent + .item + .clone() + .left() + .ok_or_else(|| tg::error!(%reference, "expected an object"))?; + let referent = referent.map(|_| item); let crate::process::spawn::Output { process, output } = self - .spawn(options, reference.clone(), Vec::new(), None, None, None) + .spawn( + options, + reference.clone(), + referent, + Vec::new(), + crate::process::spawn::Stdio::default(), + ) .boxed() .await?; diff --git a/packages/cli/tests/build/errors.nu b/packages/cli/tests/build/errors.nu index b8a5368b9..8fabca7fe 100644 --- a/packages/cli/tests/build/errors.nu +++ b/packages/cli/tests/build/errors.nu @@ -5,7 +5,7 @@ let server = spawn let path = artifact { tangram.ts: r#' export default async function () { - await tg.run(x); + await tg.build(x); }; export function x() { diff --git a/packages/cli/tests/build/modifying_artifacts_dir_in_sandbox_fails.nu b/packages/cli/tests/build/modifying_artifacts_dir_in_sandbox_fails.nu index 1d5b17770..72b910c75 100644 --- a/packages/cli/tests/build/modifying_artifacts_dir_in_sandbox_fails.nu +++ b/packages/cli/tests/build/modifying_artifacts_dir_in_sandbox_fails.nu @@ -6,10 +6,8 @@ let path = artifact { tangram.ts: ' import busybox from "busybox"; export default async () => { - const bb = tg.build(busybox); - const file = await tg.build`echo "Hello, World!" > ${tg.output}`.env(bb); - await tg.run`echo "Goodbye, Reproducibility!" > ${file}`.env(bb); - return file; + const file = await tg.build`echo "Hello, World!" > ${tg.output}`.env(busybox); + await tg.build`echo "Goodbye, Reproducibility!" > ${file}`.env(busybox); } ' } diff --git a/packages/cli/tests/run/assertion_failure.nu b/packages/cli/tests/run/assertion_failure.nu index 6672d65a4..243cec241 100644 --- a/packages/cli/tests/run/assertion_failure.nu +++ b/packages/cli/tests/run/assertion_failure.nu @@ -12,8 +12,30 @@ let path = artifact { ', } +let sandbox_output = do { cd $path; tg run --sandbox } | complete +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> Uncaught Error: failed assertion + ╭─[./tangram.ts:2:22] + 1 │ import foo from "./foo.tg.ts"; + 2 │ export default () => foo(); + · ▲ + · ╰── Uncaught Error: failed assertion + ╰──── + ╭─[./foo.tg.ts:1:25] + 1 │ export default () => tg.assert(false); + · ▲ + · ╰── Uncaught Error: failed assertion + ╰──── +' + let output = do { cd $path; tg run } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/assertion_failure.snapshot b/packages/cli/tests/run/assertion_failure.snapshot deleted file mode 100644 index a611927b5..000000000 --- a/packages/cli/tests/run/assertion_failure.snapshot +++ /dev/null @@ -1,15 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> Uncaught Error: failed assertion - ╭─[./tangram.ts:2:22] - 1 │ import foo from "./foo.tg.ts"; - 2 │ export default () => foo(); - · ▲ - · ╰── Uncaught Error: failed assertion - ╰──── - ╭─[./foo.tg.ts:1:25] - 1 │ export default () => tg.assert(false); - · ▲ - · ╰── Uncaught Error: failed assertion - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/assertion_failure_in_path_dependency.nu b/packages/cli/tests/run/assertion_failure_in_path_dependency.nu index 86ef76381..a66f8d9fa 100644 --- a/packages/cli/tests/run/assertion_failure_in_path_dependency.nu +++ b/packages/cli/tests/run/assertion_failure_in_path_dependency.nu @@ -16,9 +16,31 @@ let path = artifact { } } -let output = do { cd $path; tg run ./foo }| complete -print $output +let sandbox_output = do { cd $path; tg run ./foo --sandbox } | complete +print $sandbox_output +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> Uncaught Error: error + ╭─[./foo/tangram.ts:2:22] + 1 │ import foo from "../bar"; + 2 │ export default () => foo(); + · ▲ + · ╰── Uncaught Error: error + ╰──── + ╭─[./bar/tangram.ts:1:25] + 1 │ export default () => tg.assert(false, "error") + · ▲ + · ╰── Uncaught Error: error + ╰──── +' + +let output = do { cd $path; tg run ./foo } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/assertion_failure_in_path_dependency.snapshot b/packages/cli/tests/run/assertion_failure_in_path_dependency.snapshot deleted file mode 100644 index 69aa84a69..000000000 --- a/packages/cli/tests/run/assertion_failure_in_path_dependency.snapshot +++ /dev/null @@ -1,15 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> Uncaught Error: error - ╭─[./foo/tangram.ts:2:22] - 1 │ import foo from "../bar"; - 2 │ export default () => foo(); - · ▲ - · ╰── Uncaught Error: error - ╰──── - ╭─[./bar/tangram.ts:1:25] - 1 │ export default () => tg.assert(false, "error") - · ▲ - · ╰── Uncaught Error: error - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/assertion_failure_in_tag_dependency.nu b/packages/cli/tests/run/assertion_failure_in_tag_dependency.nu index f0cad14dc..6878d9005 100644 --- a/packages/cli/tests/run/assertion_failure_in_tag_dependency.nu +++ b/packages/cli/tests/run/assertion_failure_in_tag_dependency.nu @@ -17,8 +17,30 @@ let path = artifact { ' } +let sandbox_output = do { cd $path; tg run --sandbox } | complete +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> Uncaught Error: error in foo + ╭─[./tangram.ts:2:22] + 1 │ import foo from "foo"; + 2 │ export default () => foo(); + · ▲ + · ╰── Uncaught Error: error in foo + ╰──── + ╭─[foo:tangram.ts:1:25] + 1 │ export default () => tg.assert(false, "error in foo"); + · ▲ + · ╰── Uncaught Error: error in foo + ╰──── +' + let output = do { cd $path; tg run } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/assertion_failure_in_tag_dependency.snapshot b/packages/cli/tests/run/assertion_failure_in_tag_dependency.snapshot deleted file mode 100644 index bbd895ec1..000000000 --- a/packages/cli/tests/run/assertion_failure_in_tag_dependency.snapshot +++ /dev/null @@ -1,15 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> Uncaught Error: error in foo - ╭─[./tangram.ts:2:22] - 1 │ import foo from "foo"; - 2 │ export default () => foo(); - · ▲ - · ╰── Uncaught Error: error in foo - ╰──── - ╭─[foo:tangram.ts:1:25] - 1 │ export default () => tg.assert(false, "error in foo"); - · ▲ - · ╰── Uncaught Error: error in foo - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.nu b/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.nu index 8b03468cd..b630a9bd2 100644 --- a/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.nu +++ b/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.nu @@ -26,8 +26,44 @@ let path = artifact { ' } +let sandbox_output = do { cd $path; tg run --sandbox } | complete +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> Uncaught Error: failure in foo + ╭─[./tangram.ts:2:22] + 1 │ import foo from "foo"; + 2 │ export default () => foo(); + · ▲ + · ╰── Uncaught Error: failure in foo + ╰──── + ╭─[foo:tangram.ts:2:22] + 1 │ import bar from "../bar"; + 2 │ export default () => bar(); + · ▲ + · ╰── Uncaught Error: failure in foo + 3 │ export const failure = () => tg.assert(false, "failure in foo"); + ╰──── + ╭─[foo:../bar/tangram.ts:2:22] + 1 │ import { failure } from "../foo"; + 2 │ export default () => failure(); + · ▲ + · ╰── Uncaught Error: failure in foo + ╰──── + ╭─[foo:../foo/tangram.ts:3:33] + 2 │ export default () => bar(); + 3 │ export const failure = () => tg.assert(false, "failure in foo"); + · ▲ + · ╰── Uncaught Error: failure in foo + ╰──── +' + let output = do { cd $path; tg run } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.snapshot b/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.snapshot deleted file mode 100644 index 350e92ae8..000000000 --- a/packages/cli/tests/run/assertion_failure_in_tagged_cyclic_dependency.snapshot +++ /dev/null @@ -1,29 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> Uncaught Error: failure in foo - ╭─[./tangram.ts:2:22] - 1 │ import foo from "foo"; - 2 │ export default () => foo(); - · ▲ - · ╰── Uncaught Error: failure in foo - ╰──── - ╭─[foo:tangram.ts:2:22] - 1 │ import bar from "../bar"; - 2 │ export default () => bar(); - · ▲ - · ╰── Uncaught Error: failure in foo - 3 │ export const failure = () => tg.assert(false, "failure in foo"); - ╰──── - ╭─[foo:../bar/tangram.ts:2:22] - 1 │ import { failure } from "../foo"; - 2 │ export default () => failure(); - · ▲ - · ╰── Uncaught Error: failure in foo - ╰──── - ╭─[foo:../foo/tangram.ts:3:33] - 2 │ export default () => bar(); - 3 │ export const failure = () => tg.assert(false, "failure in foo"); - · ▲ - · ╰── Uncaught Error: failure in foo - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/assertion_failure_out_of_tree.nu b/packages/cli/tests/run/assertion_failure_out_of_tree.nu index 6408bbc6b..1b07b1134 100644 --- a/packages/cli/tests/run/assertion_failure_out_of_tree.nu +++ b/packages/cli/tests/run/assertion_failure_out_of_tree.nu @@ -16,8 +16,25 @@ let path = artifact { } } -let output = do { cd $path; tg run ./foo }| complete +let sandbox_output = do { cd $path; tg run ./foo --sandbox } | complete +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> the child process failed + id = PROCESS + -> Uncaught Error: failed assertion + ╭─[./bar/tangram.ts:1:25] + 1 │ export default () => tg.assert(false); + · ▲ + · ╰── Uncaught Error: failed assertion + ╰──── +' +let output = do { cd $path; tg run ./foo } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/assertion_failure_out_of_tree.snapshot b/packages/cli/tests/run/assertion_failure_out_of_tree.snapshot deleted file mode 100644 index d9195ee93..000000000 --- a/packages/cli/tests/run/assertion_failure_out_of_tree.snapshot +++ /dev/null @@ -1,11 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> the child process failed - id = PROCESS --> Uncaught Error: failed assertion - ╭─[./bar/tangram.ts:1:25] - 1 │ export default () => tg.assert(false); - · ▲ - · ╰── Uncaught Error: failed assertion - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/build_flag.nu b/packages/cli/tests/run/build_flag.nu new file mode 100644 index 000000000..cf75d189a --- /dev/null +++ b/packages/cli/tests/run/build_flag.nu @@ -0,0 +1,21 @@ +use ../../test.nu * + +# When --build is set, `tg run` should build the target first to produce an object, then run that object. + +let server = spawn + +let path = artifact { + tangram.ts: r#' + export default () => { + return tg.directory({ + "tangram.ts": tg.file('export default () => "from built module";'), + }); + }; + '# +} + +let sandbox_output = tg run --build $path --sandbox +snapshot $sandbox_output '"from built module"' + +let output = tg run --build $path +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/build_flag_non_object.nu b/packages/cli/tests/run/build_flag_non_object.nu new file mode 100644 index 000000000..ff6f4e02d --- /dev/null +++ b/packages/cli/tests/run/build_flag_non_object.nu @@ -0,0 +1,17 @@ +use ../../test.nu * + +# When --build is set but the build output is not an object (e.g. a string), `tg run` should fail. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "not an object";' +} + +let sandbox_output = tg run --build $path --sandbox | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "expected the build to output an object") + +let output = tg run --build $path | complete +failure $output +assert ($output.stderr | str contains "expected the build to output an object") diff --git a/packages/cli/tests/run/checkout.nu b/packages/cli/tests/run/checkout.nu new file mode 100644 index 000000000..51b9133e0 --- /dev/null +++ b/packages/cli/tests/run/checkout.nu @@ -0,0 +1,19 @@ +use ../../test.nu * + +# When --checkout is set without a path, `tg run` should check out the output artifact and print its path. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => tg.file("checkout contents");' +} + +let sandbox_output = tg run --checkout $path --sandbox | str trim +assert ($sandbox_output | str contains "/") +let sandbox_contents = open $sandbox_output +assert ($sandbox_contents == "checkout contents") + +let output = tg run --checkout $path | str trim +assert ($output | str contains "/") +let contents = open $output +assert ($contents == "checkout contents") diff --git a/packages/cli/tests/run/checkout_directory.nu b/packages/cli/tests/run/checkout_directory.nu new file mode 100644 index 000000000..48e79e024 --- /dev/null +++ b/packages/cli/tests/run/checkout_directory.nu @@ -0,0 +1,28 @@ +use ../../test.nu * + +# When --checkout is set and the output is a directory, `tg run` should check out the directory. + +let server = spawn + +let path = artifact { + tangram.ts: ' + export default () => tg.directory({ + "hello.txt": tg.file("hello"), + "world.txt": tg.file("world"), + }); + ' +} + +let sandbox_output = tg run --checkout $path --sandbox | str trim +print $sandbox_output +print (file $sandbox_output) +assert ($sandbox_output | path exists) +assert (($sandbox_output | path join "hello.txt") | path exists) +let sandbox_contents = open ($sandbox_output | path join "hello.txt") +assert ($sandbox_contents == "hello") + +# let output = tg run --checkout $path | str trim +# assert ($output | path exists) +# assert (($output | path join "hello.txt") | path exists) +# let contents = open ($output | path join "hello.txt") +# assert ($contents == "hello") diff --git a/packages/cli/tests/run/checkout_force.nu b/packages/cli/tests/run/checkout_force.nu new file mode 100644 index 000000000..0fe22972b --- /dev/null +++ b/packages/cli/tests/run/checkout_force.nu @@ -0,0 +1,23 @@ +use ../../test.nu * + +# When --checkout-force is set with --checkout=, `tg run` should overwrite an existing file at the path. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => tg.file("new contents");' +} + +let sandbox_dir = mktemp -d +let sandbox_checkout = ($sandbox_dir | path join "output.txt") +"old contents" | save $sandbox_checkout +tg run $"--checkout=($sandbox_checkout)" --checkout-force $path --sandbox +let sandbox_contents = open $sandbox_checkout +assert ($sandbox_contents == "new contents") + +let unsandboxed_dir = mktemp -d +let unsandboxed_checkout = ($unsandboxed_dir | path join "output.txt") +"old contents" | save $unsandboxed_checkout +tg run $"--checkout=($unsandboxed_checkout)" --checkout-force $path +let contents = open $unsandboxed_checkout +assert ($contents == "new contents") diff --git a/packages/cli/tests/run/checkout_no_output.nu b/packages/cli/tests/run/checkout_no_output.nu new file mode 100644 index 000000000..b7489e4b1 --- /dev/null +++ b/packages/cli/tests/run/checkout_no_output.nu @@ -0,0 +1,17 @@ +use ../../test.nu * + +# When --checkout is set but the process returns null, `tg run` should fail with an error. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => {};' +} + +let sandbox_output = tg run --checkout $path --sandbox | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "expected an output") + +let output = tg run --checkout $path | complete +failure $output +assert ($output.stderr | str contains "expected an output") diff --git a/packages/cli/tests/run/checkout_non_artifact.nu b/packages/cli/tests/run/checkout_non_artifact.nu new file mode 100644 index 000000000..9c4bbe4ef --- /dev/null +++ b/packages/cli/tests/run/checkout_non_artifact.nu @@ -0,0 +1,17 @@ +use ../../test.nu * + +# When --checkout is set but the process output is not an artifact (e.g. a string), `tg run` should fail. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "just a string";' +} + +let sandbox_output = tg run --checkout $path --sandbox | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "expected an artifact") + +let output = tg run --checkout $path | complete +failure $output +assert ($output.stderr | str contains "expected an artifact") diff --git a/packages/cli/tests/run/checkout_path.nu b/packages/cli/tests/run/checkout_path.nu new file mode 100644 index 000000000..42017ce03 --- /dev/null +++ b/packages/cli/tests/run/checkout_path.nu @@ -0,0 +1,23 @@ +use ../../test.nu * + +# When --checkout= is set, `tg run` should check out the output artifact to the specified path. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => tg.file("checkout to path");' +} + +let sandbox_dir = mktemp -d +let sandbox_checkout = ($sandbox_dir | path join "sandbox_output.txt") +let sandbox_output = tg run $"--checkout=($sandbox_checkout)" $path --sandbox | str trim +assert ($sandbox_output | str ends-with "sandbox_output.txt") +let sandbox_contents = open $sandbox_checkout +assert ($sandbox_contents == "checkout to path") + +let unsandboxed_dir = mktemp -d +let unsandboxed_checkout = ($unsandboxed_dir | path join "output.txt") +let output = tg run $"--checkout=($unsandboxed_checkout)" $path | str trim +assert ($output | str ends-with "output.txt") +let contents = open $unsandboxed_checkout +assert ($contents == "checkout to path") diff --git a/packages/cli/tests/run/child_process_error.nu b/packages/cli/tests/run/child_process_error.nu index 1b9b87f5e..84874adee 100644 --- a/packages/cli/tests/run/child_process_error.nu +++ b/packages/cli/tests/run/child_process_error.nu @@ -14,8 +14,35 @@ let path = artifact { ', } +let sandbox_output = do { cd $path; tg run --sandbox } | complete +failure $sandbox_output +let sandbox_stderr = $sandbox_output.stderr | lines | skip 1 | str join "\n" +let sandbox_stderr = $sandbox_stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' +snapshot $sandbox_stderr ' + error an error occurred + -> the process failed + id = PROCESS + -> the child process failed + id = PROCESS + ╭─[./tangram.ts:2:9] + 1 │ export default async function () { + 2 │ return await tg.run(foo); + · ▲ + · ╰── the child process failed + 3 │ } + ╰──── + -> error + ╭─[./tangram.ts:6:11] + 5 │ export function foo() { + 6 │ throw tg.error("error"); + · ▲ + · ╰── error + 7 │ } + ╰──── +' + let output = do { cd $path; tg run } | complete failure $output let stderr = $output.stderr | lines | skip 1 | str join "\n" let stderr = $stderr | str replace -ar 'pcs_00[0-9a-z]{26}' 'PROCESS' -snapshot $stderr +assert equal $stderr $sandbox_stderr diff --git a/packages/cli/tests/run/child_process_error.snapshot b/packages/cli/tests/run/child_process_error.snapshot deleted file mode 100644 index 92ba8d865..000000000 --- a/packages/cli/tests/run/child_process_error.snapshot +++ /dev/null @@ -1,20 +0,0 @@ -error an error occurred --> the process failed - id = PROCESS --> the child process failed - id = PROCESS - ╭─[./tangram.ts:2:9] - 1 │ export default async function () { - 2 │ return await tg.run(foo); - · ▲ - · ╰── the child process failed - 3 │ } - ╰──── --> error - ╭─[./tangram.ts:6:11] - 5 │ export function foo() { - 6 │ throw tg.error("error"); - · ▲ - · ╰── error - 7 │ } - ╰──── \ No newline at end of file diff --git a/packages/cli/tests/run/console_log.nu b/packages/cli/tests/run/console_log.nu new file mode 100644 index 000000000..fb30e0569 --- /dev/null +++ b/packages/cli/tests/run/console_log.nu @@ -0,0 +1,27 @@ +use ../../test.nu * + +# The process stdout from console.log should appear on the terminal. The return value should also be printed. + +let server = spawn + +let path = artifact { + tangram.ts: ' + export default () => { + console.log("log line 1"); + console.log("log line 2"); + return "return value"; + }; + ' +} + +let sandbox_output = tg run $path --sandbox | complete +success $sandbox_output +assert ($sandbox_output.stdout | str contains "log line 1") +assert ($sandbox_output.stdout | str contains "log line 2") +assert ($sandbox_output.stdout | str contains "return value") + +let output = tg run $path | complete +success $output +assert ($output.stdout | str contains "log line 1") +assert ($output.stdout | str contains "log line 2") +assert ($output.stdout | str contains "return value") diff --git a/packages/cli/tests/run/default_reference.nu b/packages/cli/tests/run/default_reference.nu new file mode 100644 index 000000000..500628a7d --- /dev/null +++ b/packages/cli/tests/run/default_reference.nu @@ -0,0 +1,17 @@ +use ../../test.nu * + +# When no reference is given, `tg run` defaults to "." and uses the current directory. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "default reference";' +} + +let sandbox_output = do { cd $path; tg run --sandbox } | complete +success $sandbox_output +assert ($sandbox_output.stdout | str contains "default reference") + +let output = do { cd $path; tg run } | complete +success $output +assert equal $output.stdout $sandbox_output.stdout diff --git a/packages/cli/tests/run/detach.nu b/packages/cli/tests/run/detach.nu new file mode 100644 index 000000000..07655e3e4 --- /dev/null +++ b/packages/cli/tests/run/detach.nu @@ -0,0 +1,15 @@ +use ../../test.nu * + +# When --detach is set, `tg run` should print the process ID and return immediately. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello";' +} + +let sandbox_output = tg run --detach $path --sandbox | str trim +assert ($sandbox_output | str starts-with "pcs_") + +let output = tg run --detach $path | str trim +assert ($output | str starts-with "pcs_") diff --git a/packages/cli/tests/run/detach_verbose.nu b/packages/cli/tests/run/detach_verbose.nu new file mode 100644 index 000000000..973d9d3ed --- /dev/null +++ b/packages/cli/tests/run/detach_verbose.nu @@ -0,0 +1,15 @@ +use ../../test.nu * + +# When --detach --verbose are set, `tg run` should print the full spawn output as JSON. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello";' +} + +let sandbox_output = tg run --detach --verbose $path --sandbox | from json +assert ($sandbox_output.process | str starts-with "pcs_") + +let output = tg run --detach --verbose $path | from json +assert ($output.process | str starts-with "pcs_") diff --git a/packages/cli/tests/run/directory_by_id.nu b/packages/cli/tests/run/directory_by_id.nu new file mode 100644 index 000000000..e304bed47 --- /dev/null +++ b/packages/cli/tests/run/directory_by_id.nu @@ -0,0 +1,16 @@ +use ../../test.nu * + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello, world!";' +} + +let id = tg checkin $path +tg index + +let sandbox_output = tg run $id --sandbox +snapshot $sandbox_output '"hello, world!"' + +let output = tg run $id +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/executable_path.nu b/packages/cli/tests/run/executable_path.nu new file mode 100644 index 000000000..e817b7270 --- /dev/null +++ b/packages/cli/tests/run/executable_path.nu @@ -0,0 +1,16 @@ +use ../../test.nu * + +# When --executable-path is set, `tg run` should resolve the executable from the directory. + +let server = spawn + +let path = artifact { + tangram.ts: ' + export default () => tg.directory({ + "main.tg.ts": tg.file("export default () => \"from executable path\";"), + }); + ' +} + +let sandbox_output = tg run --executable-path main.tg.ts --build $path +snapshot $sandbox_output '"from executable path"' diff --git a/packages/cli/tests/run/exit_code_nonzero.nu b/packages/cli/tests/run/exit_code_nonzero.nu new file mode 100644 index 000000000..0e71a8c1b --- /dev/null +++ b/packages/cli/tests/run/exit_code_nonzero.nu @@ -0,0 +1,20 @@ +use ../../test.nu * + +# When a process exits with a non-zero exit code (1 < code < 128), `tg run` should fail with an error mentioning the exit code. + +let server = spawn --busybox + +let path = artifact { + tangram.ts: ' + import busybox from "busybox"; + export default () => tg.build`exit 42`.env(tg.build(busybox)); + ' +} + +let sandbox_output = tg run $path --sandbox | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "exited with code 42") + +let output = tg run $path | complete +failure $output +assert ($output.stderr | str contains "exited with code 42") diff --git a/packages/cli/tests/run/exit_code_signal.nu b/packages/cli/tests/run/exit_code_signal.nu new file mode 100644 index 000000000..b63b94603 --- /dev/null +++ b/packages/cli/tests/run/exit_code_signal.nu @@ -0,0 +1,20 @@ +use ../../test.nu * + +# When a process exits with a signal (exit code >= 128), `tg run` should fail with an error mentioning the signal number. + +let server = spawn --busybox + +let path = artifact { + tangram.ts: ' + import busybox from "busybox"; + export default () => tg.build`kill -9 $$`.env(tg.build(busybox)); + ' +} + +let sandbox_output = tg run $path --sandbox | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "exited with signal") + +let output = tg run $path | complete +failure $output +assert ($output.stderr | str contains "exited with signal") diff --git a/packages/cli/tests/run/file_by_id.nu b/packages/cli/tests/run/file_by_id.nu index 659a6de44..8a5bce782 100644 --- a/packages/cli/tests/run/file_by_id.nu +++ b/packages/cli/tests/run/file_by_id.nu @@ -9,5 +9,8 @@ let path = artifact { let id = tg checkin ($path + '/file.tg.ts') tg index +let sandbox_output = tg run $id --sandbox +snapshot $sandbox_output '"hello, world!"' + let output = tg run $id -snapshot $output '"hello, world!"' \ No newline at end of file +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/hello_world.nu b/packages/cli/tests/run/hello_world.nu index 4399dc591..c8853a354 100644 --- a/packages/cli/tests/run/hello_world.nu +++ b/packages/cli/tests/run/hello_world.nu @@ -10,7 +10,10 @@ let path = artifact { ' } -let output = tg run $path -snapshot $output ' +let sandbox_output = tg run $path --sandbox +snapshot $sandbox_output ' Hello, World! ' + +let output = tg run $path +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/multiple_trailing_args.nu b/packages/cli/tests/run/multiple_trailing_args.nu new file mode 100644 index 000000000..2a3698b81 --- /dev/null +++ b/packages/cli/tests/run/multiple_trailing_args.nu @@ -0,0 +1,15 @@ +use ../../test.nu * + +# Test that multiple trailing arguments are all passed to the process. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default (...args: string[]) => args.join(", ");' +} + +let sandbox_output = tg run $path --sandbox -- a b c +snapshot $sandbox_output '"a, b, c"' + +let output = tg run $path -- a b c +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/print_pretty.nu b/packages/cli/tests/run/print_pretty.nu new file mode 100644 index 000000000..2437ff618 --- /dev/null +++ b/packages/cli/tests/run/print_pretty.nu @@ -0,0 +1,15 @@ +use ../../test.nu * + +# When --pretty is set, `tg run` should pretty-print the output value. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => ({ a: 1, b: 2 });' +} + +let sandbox_output = tg run --pretty $path --sandbox +assert ($sandbox_output | str contains "\n") + +let output = tg run --pretty $path +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/process_error.nu b/packages/cli/tests/run/process_error.nu new file mode 100644 index 000000000..3d24d12fe --- /dev/null +++ b/packages/cli/tests/run/process_error.nu @@ -0,0 +1,21 @@ +use ../../test.nu * + +# When a process produces an error, `tg run` should fail and print the error. + +let server = spawn + +let path = artifact { + tangram.ts: ' + export default () => { + throw new Error("something went wrong"); + }; + ' +} + +let sandbox_output = do { cd $path; tg run --sandbox } | complete +failure $sandbox_output +assert ($sandbox_output.stderr | str contains "the process failed") + +let output = do { cd $path; tg run } | complete +failure $output +assert ($output.stderr | str contains "the process failed") diff --git a/packages/cli/tests/run/quiet.nu b/packages/cli/tests/run/quiet.nu new file mode 100644 index 000000000..59a52f349 --- /dev/null +++ b/packages/cli/tests/run/quiet.nu @@ -0,0 +1,22 @@ +use ../../test.nu * + +# When --quiet is set, `tg run` should suppress the process info message on stderr. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello";' +} + +let sandboxed_output = tg -q run $path --sandbox | complete +success $sandboxed_output +assert (not ($sandboxed_output.stderr | str contains "pcs_")) +snapshot $sandboxed_output.stdout ' + "hello" + +' + +let output = tg -q run $path | complete +success $output +assert (not ($output.stderr | str contains "pcs_")) +assert equal $output.stdout $sandboxed_output.stdout diff --git a/packages/cli/tests/run/return_null.nu b/packages/cli/tests/run/return_null.nu new file mode 100644 index 000000000..c3fd819e0 --- /dev/null +++ b/packages/cli/tests/run/return_null.nu @@ -0,0 +1,15 @@ +use ../../test.nu * + +# When a process returns null, `tg run` should produce no output. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => {};' +} + +let sandbox_output = tg run $path --sandbox +snapshot $sandbox_output '' + +let output = tg run $path +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/return_value.nu b/packages/cli/tests/run/return_value.nu new file mode 100644 index 000000000..267e8a1bd --- /dev/null +++ b/packages/cli/tests/run/return_value.nu @@ -0,0 +1,13 @@ +use ../../test.nu * + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello, world!";' +} + +let sandbox_output = tg run $path --sandbox +snapshot $sandbox_output '"hello, world!"' + +let output = tg run $path +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/subpath_reference.nu b/packages/cli/tests/run/subpath_reference.nu new file mode 100644 index 000000000..8e1976b1a --- /dev/null +++ b/packages/cli/tests/run/subpath_reference.nu @@ -0,0 +1,19 @@ +use ../../test.nu * + +# Test running a specific file within a package using a subpath reference. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "from root";', + sub.tg.ts: 'export default () => "from sub";', +} + +let id = tg checkin $path +tg index + +let sandbox_output = tg run ($id + "?path=sub.tg.ts") --sandbox +snapshot $sandbox_output '"from sub"' + +let output = tg run ($id + "?path=sub.tg.ts") +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/trailing_args.nu b/packages/cli/tests/run/trailing_args.nu new file mode 100644 index 000000000..9bb823527 --- /dev/null +++ b/packages/cli/tests/run/trailing_args.nu @@ -0,0 +1,13 @@ +use ../../test.nu * + +let server = spawn + +let path = artifact { + tangram.ts: 'export default (name: string) => `Hello, ${name}!`;' +} + +let sandbox_output = tg run $path --sandbox -- Tangram +snapshot $sandbox_output '"Hello, Tangram!"' + +let output = tg run $path -- Tangram +assert equal $output $sandbox_output diff --git a/packages/cli/tests/run/verbose.nu b/packages/cli/tests/run/verbose.nu new file mode 100644 index 000000000..f8dcf9126 --- /dev/null +++ b/packages/cli/tests/run/verbose.nu @@ -0,0 +1,17 @@ +use ../../test.nu * + +# When --verbose is set, `tg run` should print the full wait output as JSON. + +let server = spawn + +let path = artifact { + tangram.ts: 'export default () => "hello";' +} + +let sandbox_output = tg run --verbose $path --sandbox | from json +print $sandbox_output +assert equal $sandbox_output.exit 0 + +let output = tg run --verbose $path | from json +print $output +assert equal $output.exit 0 diff --git a/packages/cli/tests/run/verbose_with_error.nu b/packages/cli/tests/run/verbose_with_error.nu new file mode 100644 index 000000000..a841c4470 --- /dev/null +++ b/packages/cli/tests/run/verbose_with_error.nu @@ -0,0 +1,25 @@ +use ../../test.nu * + +# When --verbose is set and the process fails, `tg run` should print the full wait output as JSON including the error. + +let server = spawn + +let path = artifact { + tangram.ts: ' + export default () => { + throw new Error("verbose error"); + }; + ' +} + +let sandbox_output = tg run --verbose $path --sandbox | complete +success $sandbox_output +let sandbox_json = $sandbox_output.stdout | from json +assert ($sandbox_json.error != null) +assert ($sandbox_json.exit == 1) + +let output = tg run --verbose $path | complete +success $output +let json = $output.stdout | from json +assert ($json.error != null) +assert ($json.exit == 1) diff --git a/packages/client/Cargo.toml b/packages/client/Cargo.toml index 38eb70799..5955a2e84 100644 --- a/packages/client/Cargo.toml +++ b/packages/client/Cargo.toml @@ -54,6 +54,7 @@ tangram_http = { workspace = true } tangram_serialize = { workspace = true } tangram_uri = { workspace = true } tangram_util = { workspace = true } +tempfile = { workspace = true } time = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true, optional = true } diff --git a/packages/client/src/run.rs b/packages/client/src/run.rs index a80f60032..a089bf0a8 100644 --- a/packages/client/src/run.rs +++ b/packages/client/src/run.rs @@ -1,6 +1,11 @@ use { crate::prelude::*, - std::{io::IsTerminal as _, path::PathBuf}, + std::{ + collections::BTreeMap, + io::IsTerminal as _, + path::{Path, PathBuf}, + sync::{LazyLock, Mutex}, + }, }; #[derive(Clone, Debug, Default)] @@ -25,10 +30,43 @@ pub struct Arg { pub user: Option, } -pub async fn run(handle: &H, arg: tg::run::Arg) -> tg::Result +pub async fn run(handle: &H, arg: Arg) -> tg::Result where H: tg::Handle, { + let host = arg + .host + .clone() + .or_else(|| { + if cfg!(target_os = "linux") { + #[cfg(target_arch = "aarch64")] + return Some("aarch64-linux".into()); + + #[cfg(target_arch = "x86_64")] + return Some("x86_64-linux".into()); + } + if cfg!(target_os = "macos") { + #[cfg(target_arch = "aarch64")] + return Some("aarch64-macos".into()); + + #[cfg(target_arch = "x86_64")] + return Some("x86_64-macos".into()); + } + None + }) + .ok_or_else(|| tg::error!("unknown host"))?; + if needs_sandbox(&arg) { + run_sandboxed(handle, host, arg).await + } else { + run_unsandboxed(handle, host, arg).await + } +} + +async fn run_sandboxed(handle: &H, host: String, arg: Arg) -> tg::Result +where + H: tg::Handle, +{ + // TODO: handle signals let state = if let Some(process) = tg::Process::current()? { Some(process.load(handle).await?) } else { @@ -39,12 +77,12 @@ where } else { None }; - let host = arg - .host - .ok_or_else(|| tg::error!("expected the host to be set"))?; + + // Get the executable. let executable = arg .executable .ok_or_else(|| tg::error!("expected the executable to be set"))?; + let mut builder = tg::Command::builder(host, executable); builder = builder.args(arg.args); let cwd = if let Some(command) = &command { @@ -166,3 +204,256 @@ where .map_err(|source| tg::error!(!source, "failed to get the process output"))?; Ok(output) } + +async fn run_unsandboxed(handle: &H, host: String, arg: Arg) -> tg::Result +where + H: tg::Handle, +{ + // Create the output directory. + let temp = tempfile::TempDir::new() + .map_err(|source| tg::error!(!source, "failed to create the temporary directory"))?; + let output_dir = temp.path().join("output"); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|source| tg::error!(!source, "failed to create the output directory"))?; + let output_path = output_dir.join(std::process::id().to_string()); + + // Get the executable. + let executable = arg + .executable + .ok_or_else(|| tg::error!("expected the executable to be set"))? + .to_data(); + + let mut args = Vec::new(); + let mut env = std::env::vars().collect::>(); + + env.remove("TANGRAM_OUTPUT"); + env.remove("TANGRAM_PROCESS"); + env.remove("TANGRAM_URL"); + + // Render the executable. + let executable = match host.as_str() { + "builtin" => { + let exe = tangram_executable_path(); + args.insert(0, "builtin".to_owned()); + args.insert(1, executable.to_string()); + exe + }, + + "js" => { + let exe = tangram_executable_path(); + args.insert(0, "js".to_owned()); + args.insert(1, executable.to_string()); + exe + }, + + _ => match executable { + tg::command::data::Executable::Artifact(executable) => { + let mut path = tg::checkout(handle, tg::checkout::Arg { + artifact: executable.artifact.clone(), + dependencies: true, + extension: None, + force: false, + lock: None, + path: None, + }).await.map_err(|source| tg::error!(!source, executable = %executable.artifact, "failed to check out the artifact"))?; + if let Some(executable_path) = &executable.path { + path.push(executable_path); + } + path + }, + tg::command::data::Executable::Module(_) => { + return Err(tg::error!("invalid executable")); + }, + tg::command::data::Executable::Path(executable) => executable.path.clone(), + }, + }; + + // Render the args and env. + args.extend(render_args(&arg.args, &output_path)?); + env.extend(render_env(&arg.env, &output_path)?); + + // Run the process. + let result = tokio::process::Command::new(executable) + .args(args) + .envs(env) + .env("TANGRAM_OUTPUT", &output_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .output() + .await + .map_err(|source| tg::error!(!source, "failed to run the process"))?; + + if !result.status.success() { + return Err(tg::error!("the process failed")); + } + + // Check in the output if it exists. + let exists = tokio::fs::try_exists(&output_path) + .await + .map_err(|source| tg::error!(!source, "failed to check if the output path exists"))?; + if exists { + let arg = tg::checkin::Arg { + options: tg::checkin::Options { + destructive: true, + deterministic: true, + ignore: false, + lock: None, + locked: true, + root: true, + ..Default::default() + }, + path: output_path, + updates: Vec::new(), + }; + let artifact = tg::checkin::checkin(handle, arg) + .await + .map_err(|source| tg::error!(!source, "failed to check in the output"))?; + Ok(artifact.into()) + } else { + Ok(tg::Value::Null) + } +} + +fn needs_sandbox(arg: &Arg) -> bool { + if arg.cached.is_some_and(|cached| cached) { + return true; + } + if arg.checksum.is_some() { + return true; + } + if arg.mounts.as_ref().is_some_and(|m| !m.is_empty()) { + return true; + } + if arg.network.is_some_and(|network| !network) { + return true; + } + if arg.remote.is_some() { + return true; + } + false +} + +pub fn render_args(args: &[tg::Value], output_path: &Path) -> tg::Result> { + args.iter() + .map(|value| render_value(value, output_path)) + .collect::>>() +} + +pub fn render_env( + env: &tg::value::Map, + output_path: &Path, +) -> tg::Result> { + let mut output = tg::value::Map::new(); + for (key, value) in env { + let mutation = match value { + tg::Value::Mutation(value) => value.clone(), + value => tg::Mutation::Set { + value: Box::new(value.clone()), + }, + }; + mutation.apply(&mut output, key)?; + } + let output = output + .iter() + .map(|(key, value)| { + let key = key.clone(); + let value = render_value(value, output_path)?; + Ok::<_, tg::Error>((key, value)) + }) + .collect::>()?; + Ok(output) +} + +pub fn render_value(value: &tg::Value, output_path: &Path) -> tg::Result { + let artifacts_path = artifacts_path(); + match value { + tg::Value::String(string) => Ok(string.clone()), + tg::Value::Template(template) => template.try_render_sync(|component| match component { + tg::template::Component::String(string) => Ok(string.clone().into()), + tg::template::Component::Artifact(artifact) => Ok(artifacts_path + .join(artifact.id().to_string()) + .to_str() + .unwrap() + .to_owned() + .into()), + tg::template::Component::Placeholder(placeholder) => { + if placeholder.name == "output" { + Ok(output_path.to_str().unwrap().to_owned().into()) + } else { + Err(tg::error!( + name = %placeholder.name, + "invalid placeholder" + )) + } + }, + }), + tg::Value::Placeholder(placeholder) => { + if placeholder.name == "output" { + Ok(output_path.to_str().unwrap().to_owned()) + } else { + Err(tg::error!( + name = %placeholder.name, + "invalid placeholder" + )) + } + }, + _ => Ok(value.to_string()), + } +} + +pub static CLOSEST_ARTIFACT_PATH: LazyLock = LazyLock::new(|| { + let mut closest_artifact_path = None; + let cwd = tangram_util::env::current_exe() + .expect("Failed to get the current directory") + .canonicalize() + .expect("failed to canonicalize current directory"); + for path in cwd.ancestors().skip(1) { + let directory = path.join(".tangram/artifacts"); + if directory.exists() { + closest_artifact_path = Some(directory); + break; + } + } + if closest_artifact_path.is_none() { + let opt_path = std::path::Path::new("/opt/tangram/artifacts"); + if opt_path.exists() { + closest_artifact_path.replace(opt_path.to_owned()); + } + } + closest_artifact_path.expect("Failed to find the closest artifact path") +}); + +pub static TANGRAM_EXECUTABLE_PATH: Mutex> = Mutex::new(None); +pub static TANGRAM_ARTIFACTS_PATH: Mutex> = Mutex::new(None); + +pub fn set_artifacts_path(p: impl AsRef) { + TANGRAM_ARTIFACTS_PATH + .lock() + .unwrap() + .replace(p.as_ref().to_owned()); +} + +pub fn artifacts_path() -> PathBuf { + TANGRAM_ARTIFACTS_PATH + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| CLOSEST_ARTIFACT_PATH.clone()) +} + +pub fn set_tangram_executable_path(p: impl AsRef) { + TANGRAM_EXECUTABLE_PATH + .lock() + .unwrap() + .replace(p.as_ref().to_owned()); +} + +pub fn tangram_executable_path() -> PathBuf { + TANGRAM_EXECUTABLE_PATH + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| "tangram".into()) +} diff --git a/packages/clients/js/src/build.ts b/packages/clients/js/src/build.ts index 5544aabc6..efdae01f6 100644 --- a/packages/clients/js/src/build.ts +++ b/packages/clients/js/src/build.ts @@ -121,8 +121,13 @@ async function inner(...args: tg.Args): Promise { stdin: undefined, stdout: undefined, }); + let id = + typeof spawnOutput.process === "string" ? spawnOutput.process : undefined; + let pid = + typeof spawnOutput.process === "number" ? spawnOutput.process : undefined; let process = new tg.Process({ - id: spawnOutput.process, + id, + pid, remote: spawnOutput.remote, state: undefined, token: spawnOutput.token, @@ -139,9 +144,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } @@ -156,9 +165,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } @@ -173,9 +186,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } diff --git a/packages/clients/js/src/index.ts b/packages/clients/js/src/index.ts index c44a2ed26..a995f9a56 100644 --- a/packages/clients/js/src/index.ts +++ b/packages/clients/js/src/index.ts @@ -28,7 +28,15 @@ import { Mutation, mutation } from "./mutation.ts"; import { Object } from "./object.ts"; import { path } from "./path.ts"; import { output, Placeholder, placeholder } from "./placeholder.ts"; -import { Process, process, setProcess } from "./process.ts"; +import { + Process, + process, + setProcess, + setSpawnUnsandboxed, + setWaitUnsandboxed, + spawnUnsandboxed, + waitUnsandboxed, +} from "./process.ts"; import type { Range } from "./range.ts"; import type { Reference } from "./reference.ts"; import { Referent } from "./referent.ts"; @@ -126,10 +134,14 @@ export { run, setHandle, setProcess, + setSpawnUnsandboxed, + setWaitUnsandboxed, sleep, + spawnUnsandboxed, symlink, template, todo, unimplemented, unreachable, + waitUnsandboxed, }; diff --git a/packages/clients/js/src/process.ts b/packages/clients/js/src/process.ts index 0528c064d..2bf63bf93 100644 --- a/packages/clients/js/src/process.ts +++ b/packages/clients/js/src/process.ts @@ -11,16 +11,32 @@ export let setProcess = (newProcess: typeof process) => { Object.assign(process, newProcess); }; +export let spawnUnsandboxed: (arg: tg.Handle.SpawnArg) => Promise; + +export let setSpawnUnsandboxed = (f: typeof spawnUnsandboxed) => { + spawnUnsandboxed = f; +}; + +export let waitUnsandboxed: ( + pid: number, +) => Promise; + +export let setWaitUnsandboxed = (f: typeof waitUnsandboxed) => { + waitUnsandboxed = f; +}; + export class Process { static current: tg.Process | undefined; - #id: tg.Process.Id; + #id: tg.Process.Id | undefined; + #pid: number | undefined; #remote: string | undefined; #token: string | undefined; #state: tg.Process.State | undefined; constructor(arg: tg.Process.ConstructorArg) { this.#id = arg.id; + this.#pid = arg.pid; this.#remote = arg.remote; this.#state = arg.state; } @@ -30,18 +46,25 @@ export class Process { } async wait(): Promise { - let remotes = undefined; - if (this.#remote) { - remotes = [this.#remote]; + if (this.#id) { + let remotes = undefined; + if (this.#remote) { + remotes = [this.#remote]; + } + let arg = { + local: undefined, + remotes, + token: this.#token, + }; + let data = await tg.handle.waitProcess(this.#id, arg); + let output = tg.Process.Wait.fromData(data); + return output; } - let arg = { - local: undefined, - remotes, - token: this.#token, - }; - let data = await tg.handle.waitProcess(this.#id, arg); - let output = tg.Process.Wait.fromData(data); - return output; + if (this.#pid) { + let data = await tg.waitUnsandboxed(this.#pid); + return tg.Process.Wait.fromData(data); + } + throw new Error("expected a process id or pid"); } static expect(value: unknown): tg.Process { @@ -54,6 +77,9 @@ export class Process { } async load(): Promise { + if (!this.#id) { + throw new Error("expected the process id to be set"); + } let data = await tg.handle.getProcess(this.#id, this.#remote); this.#state = tg.Process.State.fromData(data); } @@ -62,10 +88,14 @@ export class Process { await this.load(); } - get id(): tg.Process.Id { + get id(): tg.Process.Id | undefined { return this.#id; } + get pid(): number | undefined { + return this.#pid; + } + get command(): Promise { return (async () => { await this.load(); @@ -138,7 +168,8 @@ export namespace Process { export type Id = string; export type ConstructorArg = { - id: tg.Process.Id; + id?: tg.Process.Id | undefined; + pid?: number | undefined; remote?: string | undefined; state?: State | undefined; token?: string | undefined; @@ -328,4 +359,46 @@ export namespace Process { return output; }; } + + export type WaitUnsandboxedOutput = { + error: tg.Error | undefined; + exit: number; + output?: tg.Value; + stdout?: Uint8Array; + stderr?: Uint8Array; + }; + + export namespace WaitUnsandboxedOutput { + export type Data = { + error?: tg.Error.Data | tg.Error.Id; + exit: number; + output?: tg.Value.Data; + stdout?: Uint8Array; + stderr?: Uint8Array; + }; + + export let fromData = ( + data: tg.Process.WaitUnsandboxedOutput.Data, + ): tg.Process.WaitUnsandboxedOutput => { + let output: WaitUnsandboxedOutput = { + error: + data.error !== undefined + ? typeof data.error === "string" + ? tg.Error.withId(data.error) + : tg.Error.fromData(data.error) + : undefined, + exit: data.exit, + }; + if ("output" in data) { + output.output = tg.Value.fromData(data.output); + } + if (data.stdout !== undefined) { + output.stdout = data.stdout; + } + if (data.stderr !== undefined) { + output.stderr = data.stderr; + } + return output; + }; + } } diff --git a/packages/clients/js/src/run.ts b/packages/clients/js/src/run.ts index 82d4aea0b..d68048fd9 100644 --- a/packages/clients/js/src/run.ts +++ b/packages/clients/js/src/run.ts @@ -143,12 +143,13 @@ async function inner(...args: tg.Args): Promise { "network" in arg ? (arg.network ?? false) : (tg.Process.current?.state?.network ?? false); + let user = "user" in arg ? arg.user : undefined; let commandId = await command.store(); let commandReferent = { item: commandId, options: sourceOptions, }; - let spawnOutput = await tg.handle.spawnProcess({ + let spawnArg = { checksum, command: commandReferent, create: false, @@ -160,18 +161,36 @@ async function inner(...args: tg.Args): Promise { stderr, stdin: processStdin, stdout, - }); - let process = new tg.Process({ - id: spawnOutput.process, - remote: spawnOutput.remote, - state: undefined, - token: spawnOutput.token, + }; + let sandboxed = needsSandbox({ + checksum, + mounts: processMounts, + network, + stdout, + stderr, + user, }); - let wait = - spawnOutput.wait !== undefined - ? tg.Process.Wait.fromData(spawnOutput.wait) - : await process.wait(); + let process: tg.Process; + let wait: tg.Process.Wait; + if (sandboxed) { + let spawnOutput = await tg.handle.spawnProcess(spawnArg); + process = new tg.Process({ + id: spawnOutput.process, + remote: spawnOutput.remote, + state: undefined, + token: spawnOutput.token, + }); + wait = + spawnOutput.wait !== undefined + ? tg.Process.Wait.fromData(spawnOutput.wait) + : await process.wait(); + } else { + let pid = await tg.spawnUnsandboxed(spawnArg); + process = new tg.Process({ pid }); + let spawnOutput = await tg.waitUnsandboxed(pid); + wait = tg.Process.Wait.fromData(spawnOutput); + } if (wait.error !== undefined) { let error = wait.error; @@ -179,9 +198,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } @@ -196,9 +219,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } @@ -213,9 +240,13 @@ async function inner(...args: tg.Args): Promise { item: error, options: sourceOptions, }; - const values: { [key: string]: string } = { - id: process.id, - }; + const values: { [key: string]: string } = {}; + if (process.id !== undefined) { + values.id = process.id; + } + if (process.pid !== undefined) { + values.pid = process.pid.toString(); + } if (sourceOptions.name !== undefined) { values.name = sourceOptions.name; } @@ -395,3 +426,32 @@ export class RunBuilder< .then(onfulfilled, onrejected); } } + +function needsSandbox(arg: { + checksum: tg.Checksum | undefined; + mounts: Array; + network: boolean; + stdout: string | undefined; + stderr: string | undefined; + user: string | undefined; +}): boolean { + if (arg.checksum !== undefined) { + return true; + } + if (arg.mounts.length > 0) { + return true; + } + if (arg.network) { + return true; + } + if (arg.stdout !== undefined) { + return true; + } + if (arg.stderr !== undefined) { + return true; + } + if (arg.user !== undefined) { + return true; + } + return false; +} diff --git a/packages/js/Cargo.toml b/packages/js/Cargo.toml index b11683cde..869aa68c1 100644 --- a/packages/js/Cargo.toml +++ b/packages/js/Cargo.toml @@ -40,8 +40,11 @@ tangram_client = { workspace = true } tangram_compiler = { workspace = true } tangram_either = { workspace = true } tangram_futures = { workspace = true } +tangram_util = {workspace = true} tangram_v8 = { workspace = true, optional = true } +tempfile = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } toml = { workspace = true } +xattr = { workspace = true } v8 = { workspace = true, optional = true } diff --git a/packages/js/src/handle.ts b/packages/js/src/handle.ts index 3e5433593..749d8fd07 100644 --- a/packages/js/src/handle.ts +++ b/packages/js/src/handle.ts @@ -25,14 +25,14 @@ export let handle: tg.Handle = { }, spawnProcess(arg: tg.Handle.SpawnArg): Promise { - return syscall("process_spawn", arg); + return syscall("process_spawn_sandboxed", arg); }, waitProcess( id: tg.Process.Id, arg: tg.Handle.WaitArg, ): Promise { - return syscall("process_wait", id, arg); + return syscall("process_wait_sandboxed", id, arg); }, checksum( diff --git a/packages/js/src/lib.rs b/packages/js/src/lib.rs index 6cdc87a2e..b1424445b 100644 --- a/packages/js/src/lib.rs +++ b/packages/js/src/lib.rs @@ -5,6 +5,8 @@ pub mod quickjs; #[cfg(feature = "v8")] pub mod v8; +pub mod process; + #[cfg(all(feature = "quickjs", not(feature = "v8")))] pub use self::quickjs::{Abort, run}; #[cfg(feature = "v8")] diff --git a/packages/js/src/main.ts b/packages/js/src/main.ts index e44e97d5d..e406549a5 100644 --- a/packages/js/src/main.ts +++ b/packages/js/src/main.ts @@ -22,3 +22,13 @@ Object.defineProperties(globalThis, { Object.defineProperty(globalThis, "start", { value: start }); tg.setHandle(handle); + +tg.setSpawnUnsandboxed(async (arg: tg.Handle.SpawnArg): Promise => { + return syscall("process_spawn_unsandboxed", arg); +}); + +tg.setWaitUnsandboxed( + async (pid: number): Promise => { + return syscall("process_wait_unsandboxed", pid); + }, +); diff --git a/packages/js/src/process.rs b/packages/js/src/process.rs new file mode 100644 index 000000000..7b28f8abf --- /dev/null +++ b/packages/js/src/process.rs @@ -0,0 +1,207 @@ +use std::{collections::BTreeMap, os::unix::process::ExitStatusExt}; + +use num::ToPrimitive; +use tangram_client::prelude::*; +use tempfile::TempDir; + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct WaitOutput { + pub exit: u8, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdout: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stderr: Option>, +} + +pub(crate) struct ChildProcess { + pub(crate) child: tokio::process::Child, + output_path: std::path::PathBuf, + _temp: TempDir, +} + +pub(crate) async fn spawn_unsandboxed( + handle: &H, + arg: tg::process::spawn::Arg, +) -> tg::Result +where + H: tg::Handle, +{ + // Get the command data. + let command_id = arg.command.item.clone(); + let command = tg::Command::with_id(command_id.clone()); + let command = command + .data(handle) + .await + .map_err(|source| tg::error!(!source, "failed to get the command data"))?; + + // Render the executable. + let host = command.host.clone(); + let mut args = Vec::new(); + let mut env = std::env::vars().collect::>(); + + let executable = match host.as_str() { + "builtin" => { + let exe = tg::run::tangram_executable_path(); + args.insert(0, "builtin".to_owned()); + args.insert(1, command.executable.to_string()); + exe + }, + + "js" => { + let exe = tg::run::tangram_executable_path(); + args.insert(0, "js".to_owned()); + args.insert(1, command.executable.to_string()); + exe + }, + + _ => match &command.executable { + tg::command::data::Executable::Artifact(executable) => { + let mut path = tg::checkout(handle,tg::checkout::Arg { + artifact: executable.artifact.clone(), + dependencies: true, + extension: None, + force: false, + lock: None, + path: None, + }).await.map_err(|source| tg::error!(!source, executable = %executable.artifact, "failed to check out the artifact"))?; + if let Some(executable_path) = &executable.path { + path.push(executable_path); + } + path + }, + tg::command::data::Executable::Module(_) => { + return Err(tg::error!("invalid executable")); + }, + tg::command::data::Executable::Path(executable) => executable.path.clone(), + }, + }; + + // Create a temp for the output. + let temp = tempfile::tempdir() + .map_err(|source| tg::error!(!source, "failed to create the temporary directory"))?; + let output_dir = temp.path().join("output"); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|source| tg::error!(!source, "failed to create the output directory"))?; + let output_path = output_dir.join(command_id.to_string()); + + // Convert data. + let args_: Vec = command + .args + .into_iter() + .map(tg::Value::try_from_data) + .collect::>()?; + let env_: tg::value::Map = command + .env + .into_iter() + .map(|(k, v)| Ok::<_, tg::Error>((k, tg::Value::try_from_data(v)?))) + .collect::>()?; + + // Render the args and env. + args.extend(tg::run::render_args(&args_, &output_path)?); + env.extend(tg::run::render_env(&env_, &output_path)?); + + // Spawn the process with piped stdout and stderr. + let child = tokio::process::Command::new(executable) + .args(args) + .envs(env) + .env("TANGRAM_OUTPUT", &output_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .map_err(|source| tg::error!(!source, "failed to spawn the process"))?; + + Ok(ChildProcess { + child, + output_path, + _temp: temp, + }) +} + +pub(crate) async fn wait_unsandboxed(handle: &H, child: ChildProcess) -> tg::Result +where + H: tg::Handle, +{ + let output = child + .child + .wait_with_output() + .await + .map_err(|source| tg::error!(!source, "failed to wait for the output"))?; + let status = output.status; + let exit = None + .or(status.code()) + .or(status.signal().map(|signal| 128 + signal)) + .unwrap() + .to_u8() + .unwrap(); + let stdout = output.stdout; + let stderr = output.stderr; + let output_path = child.output_path; + let mut error = None; + let mut output = None; + + // Try to read the user.tangram.output xattr. + if let Ok(Some(bytes)) = xattr::get(&output_path, "user.tangram.output") { + let tgon = String::from_utf8(bytes) + .map_err(|source| tg::error!(!source, "failed to decode the output xattr"))?; + let value: tg::Value = tgon + .parse() + .map_err(|source| tg::error!(!source, "failed to parse the output xattr"))?; + output.replace(value.to_data()); + } + + // Try to read the user.tangram.error xattr. + if let Ok(Some(bytes)) = xattr::get(&output_path, "user.tangram.error") { + let data = serde_json::from_slice::(&bytes) + .map_err(|source| tg::error!(!source, "failed to deserialize the error xattr"))?; + error.replace(tg::Either::Left(data)); + } + + // If no xattr output was set but the output path exists, check it in destructively. + if output.is_none() && matches!(tokio::fs::try_exists(&output_path).await, Ok(true)) { + let result = tg::checkin( + handle, + tg::checkin::Arg { + path: output_path, + options: tg::checkin::Options { + destructive: true, + deterministic: true, + ignore: false, + lock: None, + locked: true, + root: true, + ..Default::default() + }, + updates: Vec::new(), + }, + ) + .await + .map_err(|source| tg::error!(!source, "failed to check in the output")); + match result { + Ok(artifact) => { + output.replace(tg::value::Data::Object(artifact.id().into())); + }, + Err(e) => { + let e = e.to_data_or_id(); + error.replace(e); + }, + } + } + let output = WaitOutput { + exit, + output, + error, + stdout: (!stdout.is_empty()).then_some(stdout), + stderr: (!stderr.is_empty()).then_some(stderr), + }; + Ok(output) +} diff --git a/packages/js/src/quickjs.rs b/packages/js/src/quickjs.rs index c330fe367..d7e14ecee 100644 --- a/packages/js/src/quickjs.rs +++ b/packages/js/src/quickjs.rs @@ -7,7 +7,7 @@ use { crate::{Logger, Output}, rquickjs::{self as qjs, CatchResultExt as _}, sourcemap::SourceMap, - std::{cell::RefCell, path::PathBuf, rc::Rc}, + std::{cell::RefCell, collections::BTreeMap, path::PathBuf, rc::Rc}, tangram_client::prelude::*, }; @@ -22,6 +22,7 @@ const BYTECODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/main.bytecode" const SOURCE_MAP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/main.js.map")); struct State { + children: RefCell>, global_source_map: Option, logger: Logger, main_runtime_handle: tokio::runtime::Handle, @@ -110,6 +111,7 @@ where // Create the state. let state = Rc::new(State { + children: RefCell::new(BTreeMap::new()), global_source_map: SourceMap::from_slice(SOURCE_MAP).ok(), logger, main_runtime_handle, diff --git a/packages/js/src/quickjs/syscall.rs b/packages/js/src/quickjs/syscall.rs index 03981547a..f0d828ea7 100644 --- a/packages/js/src/quickjs/syscall.rs +++ b/packages/js/src/quickjs/syscall.rs @@ -12,7 +12,7 @@ mod encoding; mod log; mod magic; mod object; -mod process; +pub(crate) mod process; mod sleep; struct Result(tg::Result); @@ -52,8 +52,16 @@ pub fn syscall<'js>( "object_get" => qjs::Function::new(ctx.clone(), Async(object::get)), "object_id" => qjs::Function::new(ctx.clone(), object::id), "process_get" => qjs::Function::new(ctx.clone(), Async(process::get)), - "process_spawn" => qjs::Function::new(ctx.clone(), Async(process::spawn)), - "process_wait" => qjs::Function::new(ctx.clone(), Async(process::wait)), + "process_spawn_sandboxed" => { + qjs::Function::new(ctx.clone(), Async(process::spawn_sandboxed)) + }, + "process_wait_sandboxed" => qjs::Function::new(ctx.clone(), Async(process::wait_sandboxed)), + "process_spawn_unsandboxed" => { + qjs::Function::new(ctx.clone(), Async(process::spawn_unsandboxed)) + }, + "process_wait_unsandboxed" => { + qjs::Function::new(ctx.clone(), Async(process::wait_unsandboxed)) + }, "read" => qjs::Function::new(ctx.clone(), Async(blob::read)), "sleep" => qjs::Function::new(ctx.clone(), Async(sleep::sleep)), "write" => qjs::Function::new(ctx.clone(), Async(blob::write)), diff --git a/packages/js/src/quickjs/syscall/process.rs b/packages/js/src/quickjs/syscall/process.rs index 8921e14b5..55387e410 100644 --- a/packages/js/src/quickjs/syscall/process.rs +++ b/packages/js/src/quickjs/syscall/process.rs @@ -6,7 +6,7 @@ use { tangram_client::prelude::*, }; -pub async fn get( +pub(super) async fn get( ctx: qjs::Ctx<'_>, id: Serde, ) -> Result> { @@ -32,7 +32,7 @@ pub async fn get( Result(result.map(Serde)) } -pub async fn spawn( +pub(super) async fn spawn_sandboxed( ctx: qjs::Ctx<'_>, arg: Serde, ) -> Result> { @@ -70,7 +70,39 @@ pub async fn spawn( Result(result.map(Serde)) } -pub async fn wait( +pub(super) async fn spawn_unsandboxed( + ctx: qjs::Ctx<'_>, + arg: Serde, +) -> Result> { + let state = ctx.userdata::().unwrap().clone(); + let Serde(arg) = arg; + let result = async { + let handle = state.handle.clone(); + let child_process = state + .main_runtime_handle + .spawn(async move { crate::process::spawn_unsandboxed(&handle, arg).await }) + .await + .map_err(|source| tg::error!(!source, "the task panicked"))? + .map_err(|source| tg::error!(!source, "failed to spawn the unsandboxed process"))?; + + // Get the PID. + #[allow(clippy::cast_possible_wrap)] + let pid = child_process + .child + .id() + .ok_or_else(|| tg::error!("failed to get the child process ID"))? + .cast_signed(); + + // Store the child process in state. + state.children.borrow_mut().insert(pid, child_process); + + Ok(pid) + } + .await; + Result(result.map(Serde)) +} + +pub(super) async fn wait_sandboxed( ctx: qjs::Ctx<'_>, id: Serde, arg: Serde, @@ -95,3 +127,32 @@ pub async fn wait( .await; Result(result.map(Serde)) } + +pub(super) async fn wait_unsandboxed( + ctx: qjs::Ctx<'_>, + pid: Serde, +) -> Result> { + let state = ctx.userdata::().unwrap().clone(); + let Serde(pid) = pid; + let result = async { + // Remove the child process from state. + let child_process = state + .children + .borrow_mut() + .remove(&pid) + .ok_or_else(|| tg::error!(%pid, "unknown child process"))?; + + // Wait for the child process to complete. + let handle = state.handle.clone(); + let output = state + .main_runtime_handle + .spawn(async move { crate::process::wait_unsandboxed(&handle, child_process).await }) + .await + .map_err(|source| tg::error!(!source, "the task panicked"))? + .map_err(|source| tg::error!(!source, "failed to wait for the unsandboxed process"))?; + + Ok(output) + } + .await; + Result(result.map(Serde)) +} diff --git a/packages/js/src/syscall.ts b/packages/js/src/syscall.ts index 908045107..ec5c89a4a 100644 --- a/packages/js/src/syscall.ts +++ b/packages/js/src/syscall.ts @@ -67,16 +67,26 @@ declare global { ): Promise; function syscall( - syscall: "process_spawn", + syscall: "process_spawn_sandboxed", arg: tg.Handle.SpawnArg, ): Promise; function syscall( - syscall: "process_wait", + syscall: "process_spawn_unsandboxed", + arg: tg.Handle.SpawnArg, + ): Promise; + + function syscall( + syscall: "process_wait_sandboxed", id: tg.Process.Id, arg: tg.Handle.WaitArg, ): Promise; + function syscall( + syscall: "process_wait_unsandboxed", + pid: number, + ): Promise; + function syscall( syscall: "read", arg: tg.Handle.ReadArg, diff --git a/packages/js/src/v8.rs b/packages/js/src/v8.rs index c1528c328..9cc633cb1 100644 --- a/packages/js/src/v8.rs +++ b/packages/js/src/v8.rs @@ -12,7 +12,10 @@ use { stream::FuturesUnordered, }, sourcemap::SourceMap, - std::{cell::RefCell, future::poll_fn, path::PathBuf, pin::pin, rc::Rc, task::Poll}, + std::{ + cell::RefCell, collections::BTreeMap, future::poll_fn, path::PathBuf, pin::pin, rc::Rc, + task::Poll, + }, tangram_client::prelude::*, tangram_v8::{Deserialize as _, Serde, Serialize as _}, }; @@ -26,6 +29,7 @@ const SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/main.heapsnaps const SOURCE_MAP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/main.js.map")); struct State { + children: RefCell>, promises: RefCell>>, global_source_map: Option, logger: Logger, @@ -90,6 +94,7 @@ where // Create the state. let (rejection, _) = tokio::sync::watch::channel(None); let state = Rc::new(State { + children: RefCell::new(BTreeMap::new()), promises: RefCell::new(FuturesUnordered::new()), global_source_map: Some(SourceMap::from_slice(SOURCE_MAP).unwrap()), logger, diff --git a/packages/js/src/v8/syscall.rs b/packages/js/src/v8/syscall.rs index c14bc54fd..21bc544e2 100644 --- a/packages/js/src/v8/syscall.rs +++ b/packages/js/src/v8/syscall.rs @@ -8,7 +8,7 @@ mod checksum; mod encoding; mod magic; mod object; -mod process; +pub(crate) mod process; mod sleep; pub mod log; @@ -42,8 +42,10 @@ pub fn syscall<'s>( "object_get" => async_(scope, &args, self::object::get), "object_id" => sync(scope, &args, self::object::id), "process_get" => async_(scope, &args, self::process::get), - "process_spawn" => async_(scope, &args, self::process::spawn), - "process_wait" => async_(scope, &args, self::process::wait), + "process_spawn_sandboxed" => async_(scope, &args, self::process::spawn_sandboxed), + "process_wait_sandboxed" => async_(scope, &args, self::process::wait_sandboxed), + "process_spawn_unsandboxed" => async_(scope, &args, self::process::spawn_unsandboxed), + "process_wait_unsandboxed" => async_(scope, &args, self::process::wait_unsandboxed), "read" => async_(scope, &args, self::blob::read), "sleep" => async_(scope, &args, self::sleep::sleep), "write" => async_(scope, &args, self::blob::write), diff --git a/packages/js/src/v8/syscall/process.rs b/packages/js/src/v8/syscall/process.rs index 07c3f1d8c..18d560cce 100644 --- a/packages/js/src/v8/syscall/process.rs +++ b/packages/js/src/v8/syscall/process.rs @@ -24,7 +24,7 @@ pub async fn get( Ok(Serde(data)) } -pub async fn spawn( +pub async fn spawn_sandboxed( state: Rc, args: (Serde,), ) -> tg::Result> { @@ -56,7 +56,34 @@ pub async fn spawn( Ok(Serde(output)) } -pub async fn wait( +pub async fn spawn_unsandboxed( + state: Rc, + args: (Serde,), +) -> tg::Result> { + let (Serde(arg),) = args; + let handle = state.handle.clone(); + let child_process = state + .main_runtime_handle + .spawn(async move { crate::process::spawn_unsandboxed(&handle, arg).await }) + .await + .map_err(|source| tg::error!(!source, "the task panicked"))? + .map_err(|source| tg::error!(!source, "failed to spawn the unsandboxed process"))?; + + // Get the PID. + #[allow(clippy::cast_possible_wrap)] + let pid = child_process + .child + .id() + .ok_or_else(|| tg::error!("failed to get the child process ID"))? + .cast_signed(); + + // Store the child process in state. + state.children.borrow_mut().insert(pid, child_process); + + Ok(Serde(pid)) +} + +pub async fn wait_sandboxed( state: Rc, args: (Serde, Serde), ) -> tg::Result> { @@ -72,3 +99,28 @@ pub async fn wait( .unwrap()?; Ok(Serde(output)) } + +pub async fn wait_unsandboxed( + state: Rc, + args: (Serde,), +) -> tg::Result> { + let (Serde(pid),) = args; + + // Remove the child process from state. + let child_process = state + .children + .borrow_mut() + .remove(&pid) + .ok_or_else(|| tg::error!(%pid, "unknown child process"))?; + + // Wait for the child process to complete. + let handle = state.handle.clone(); + let output = state + .main_runtime_handle + .spawn(async move { crate::process::wait_unsandboxed(&handle, child_process).await }) + .await + .map_err(|source| tg::error!(!source, "the task panicked"))? + .map_err(|source| tg::error!(!source, "failed to wait for the unsandboxed process"))?; + + Ok(Serde(output)) +}