diff --git a/README.md b/README.md index 7efbed28..0c1862a3 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,38 @@ You can customize some of `shpool`s behavior by editing your `~/.config/shpool/config.toml` file. For an in depth discussion of configuration options see [CONFIG.md](./CONFIG.md). +### Templates + +`shpool` supports a template syntax for generating values based on a +central list of variables. This operates much like the shell environment. +In shpool templates, variable substitution used `{var}` syntax. + +Currently templates are supported in the following places: + +* session names +* the `attach --dir` flag +* the `attach --cmd` flag +* the `attach --start-cmd` flag + +The main purpose of templates is to support switching multiple sessions at +once. Whenever a variable is changed with `shpool var set +`, the shpool daemon will broadcast the complete variable set +to all `shpool attach` processes so they can recompute all their templates. +If the session name has changed as a result of this re-evaluation process, +the `shpool attach` process will automatically hang up and reconnect to the +new session. This allows you to have multiple terminals open that all switch +the shpool session they are attached to at once. For example, if you start +with the variable setting `{workspace=shpool}` and then run `shpool attach +'{workspace}-edit'` in one terminal and `shpool attach '{workspace}-main'`, +you would initially connect to the `shpool-edit` and `shpool-main` sessions. +You use these sessions to work on a patch for shpool for a while, but then +halfway through you have to quickly fix a bug in your company's codebase +so you run `shpool var set workspace yourco`. `shpool` will automatically +disconnect from `shpool-edit` and `shpool-main` and connect to `yourco-edit` +and `yourco-main`. After you finish your quick fix, you run `shpool var set +workspace shpool` and you're right back where you were when you were working +on the shpool patch. + ### Keybindings `shpool` supports keybindings (well really for the moment it @@ -129,6 +161,10 @@ to your `~/.bashrc`. ### Subcommands +#### shpool version + +Show the current shpool version. + #### shpool daemon The `daemon` subcommand causes `shpool` to run in daemon mode. When running in @@ -147,7 +183,8 @@ session will last. #### shpool list -Lists all the current shell sessions. +Lists all the current shell sessions. Supports a --json flag for a more machine +friendly output format. #### shpool detach @@ -159,6 +196,18 @@ session with no session name arguments. Kills a named shell session. +#### shpool var + +Manipulate shpool variables. Variables can be used in shpool session names using +`{var}` syntax. See the templates section above for more on how to use shpool +variables. + +#### shpool set-log-level + +Dynamically change the logging level of the shpool daemon. This is a diagnostic +tool to aid in debugging when the daemon gets in a bad state without having to +run at a verbose logging level all the time. + ### (Optional) Automatically Connect to shpool #### Explicitly named sessions diff --git a/libshpool/src/attach.rs b/libshpool/src/attach.rs index 3f15d22d..f96bec3d 100644 --- a/libshpool/src/attach.rs +++ b/libshpool/src/attach.rs @@ -41,6 +41,7 @@ const MAX_FORCE_RETRIES: usize = 20; #[allow(clippy::too_many_arguments)] pub fn run( + socket: PathBuf, config_manager: config::Manager, name: String, force: bool, @@ -48,15 +49,32 @@ pub fn run( ttl: Option, cmd: Option, dir: Option, - socket: PathBuf, + start_cmd: Option, ) -> anyhow::Result<()> { info!("\n\n======================== STARTING ATTACH ============================\n\n"); test_hooks::emit("attach-startup"); - let session_name_tmpl = template::Template::new(&name).context("parsing session name tmpl")?; + let tmpls = Templates { + session_name: template::Template::new(&name).context("parsing session name tmpl")?, + dir: if let Some(d) = dir { + Some(template::Template::new(&d).context("parsing dir tmpl")?) + } else { + None + }, + cmd: if let Some(c) = cmd { + Some(template::Template::new(&c).context("parsing cmd tmpl")?) + } else { + None + }, + start_cmd: if let Some(c) = start_cmd { + Some(template::Template::new(&c).context("parsing start cmd tmpl")?) + } else { + None + }, + }; let ttl = match &ttl { - Some(src) => match duration::parse(src.as_str()) { + Some(src) => match duration::parse(src) { Ok(d) => Some(d), Err(e) => { bail!("could not parse ttl: {:?}", e); @@ -65,20 +83,17 @@ pub fn run( None => None, }; - let attach = - Attach { config_manager, session_name_tmpl, force, background, ttl, cmd, dir, socket }; + let attach = Attach { config_manager, force, background, ttl, tmpls, socket }; attach.run() } struct Attach { config_manager: config::Manager, - session_name_tmpl: template::Template, force: bool, background: bool, ttl: Option, - cmd: Option, - dir: Option, + tmpls: Templates, socket: PathBuf, } @@ -93,49 +108,49 @@ impl Attach { let mut maybe_switch: MaybeSwitch = client.read_reply().context("reading reply")?; let var_map = maybe_switch.vars.iter().cloned().collect(); - let mut resolved_name = self.session_name_tmpl.apply(&var_map); + let mut resolved = self.tmpls.apply(&var_map); let sig_handler_session_name_slot = if !self.background { - Some(SignalHandler::new(resolved_name.clone(), self.socket.clone()).spawn()?) + Some(SignalHandler::new(resolved.session_name.clone(), self.socket.clone()).spawn()?) } else { None }; info!("looping on attach_with_name"); loop { - info!("attaching to '{}'", resolved_name); - match self.attach_with_name(resolved_name) { + info!("attaching to '{}'", resolved.session_name); + match self.attach_resolved(resolved) { Ok(AttachResult::Done) => return Ok(()), Ok(AttachResult::Switch(s)) => maybe_switch = s, Err(e) => return Err(e), } let var_map = maybe_switch.vars.iter().cloned().collect(); - resolved_name = self.session_name_tmpl.apply(&var_map); + resolved = self.tmpls.apply(&var_map); if let Some(ref slot) = sig_handler_session_name_slot { let mut slot = slot.lock().unwrap(); - *slot = resolved_name.clone(); + *slot = resolved.session_name.clone(); } } } /// Attach with the given resolved name. This will run until exit or until /// we need to reconnect due to - pub fn attach_with_name(&self, resolved_name: String) -> anyhow::Result { - if resolved_name.is_empty() { + pub fn attach_resolved(&self, resolved: ResolvedTemplates) -> anyhow::Result { + if resolved.session_name.is_empty() { eprintln!("blank session names are not allowed"); return Ok(AttachResult::Done); } - if resolved_name.contains(char::is_whitespace) { - eprintln!("session name '{}' may not have whitespace", resolved_name); + if resolved.session_name.contains(char::is_whitespace) { + eprintln!("session name '{}' may not have whitespace", resolved.session_name); return Ok(AttachResult::Done); } - if resolved_name.chars().any(|c| '/' == c) { + if resolved.session_name.chars().any(|c| '/' == c) { eprintln!("session names may not contain slashes"); return Ok(AttachResult::Done); } - if resolved_name == "." || resolved_name == ".." { + if resolved.session_name == "." || resolved.session_name == ".." { eprintln!("session names may not be special directory names"); return Ok(AttachResult::Done); } @@ -143,11 +158,14 @@ impl Attach { let mut detached = false; let mut tries = 0; let attach_client = loop { - match self.dial_attach(resolved_name.as_str()) { + match self.dial_attach(&resolved) { Ok(client) => break client, Err(err) => match err.downcast() { Ok(BusyError) if !self.force => { - eprintln!("session '{resolved_name}' already has a terminal attached"); + eprintln!( + "session '{}' already has a terminal attached", + resolved.session_name + ); return Ok(AttachResult::Done); } Ok(BusyError) => { @@ -155,13 +173,16 @@ impl Attach { let mut client = self.dial_client(true)?; client .write_connect_header(ConnectHeader::Detach(DetachRequest { - sessions: vec![resolved_name.clone()], + sessions: vec![resolved.session_name.clone()], })) .context("writing detach request header")?; let detach_reply: DetachReply = client.read_reply().context("reading reply")?; if !detach_reply.not_found_sessions.is_empty() { - warn!("could not find session '{}' to detach it", resolved_name); + warn!( + "could not find session '{}' to detach it", + resolved.session_name + ); } detached = true; @@ -169,7 +190,8 @@ impl Attach { thread::sleep(time::Duration::from_millis(100)); if tries > MAX_FORCE_RETRIES { - eprintln!("session '{resolved_name}' already has a terminal which remains attached even after attempting to detach it"); + eprintln!("session '{}' already has a terminal which remains attached even after attempting to detach it", + resolved.session_name); return Err(anyhow!("could not detach session, forced attach failed")); } tries += 1; @@ -188,27 +210,27 @@ impl Attach { let mut client = self.dial_client(true)?; client .write_connect_header(ConnectHeader::Detach(DetachRequest { - sessions: vec![resolved_name.clone()], + sessions: vec![resolved.session_name.clone()], })) .context("writing detach request header")?; let detach_reply: DetachReply = client.read_reply().context("reading reply")?; if !detach_reply.not_found_sessions.is_empty() { - warn!("could not find session '{}' to detach it", resolved_name); + warn!("could not find session '{}' to detach it", resolved.session_name); } if !detach_reply.not_attached_sessions.is_empty() { debug!( "session '{}' was already detached while processing background detach request (expected)", - resolved_name + resolved.session_name ); } return Ok(AttachResult::Done); } info!("entering bidi streaming mode"); - let session_name_tmpl = self.session_name_tmpl.clone(); + let session_name_tmpl = self.tmpls.session_name.clone(); match attach_client.pipe_bytes(move |maybe_switch: &MaybeSwitch| { let var_map: HashMap = maybe_switch.vars.iter().cloned().collect(); - session_name_tmpl.apply(&var_map) != resolved_name + session_name_tmpl.apply(&var_map) != resolved.session_name }) { Ok(PipeBytesResult::Exit(exit_status)) => std::process::exit(exit_status), Ok(PipeBytesResult::MaybeSwitch(s)) => Ok(AttachResult::Switch(s)), @@ -218,7 +240,7 @@ impl Attach { /// Attach to a session and return the connected client without piping /// stdio. - fn dial_attach(&self, name: &str) -> anyhow::Result { + fn dial_attach(&self, resolved: &ResolvedTemplates) -> anyhow::Result { let mut client = self.dial_client(true)?; let tty_size = match TtySize::from_fd(0) { @@ -241,7 +263,7 @@ impl Attach { let cwd = String::from(env::current_dir().context("getting cwd")?.to_string_lossy()); let default_dir = self.config_manager.get().default_dir.clone().unwrap_or(String::from("$HOME")); - let start_dir = match (default_dir.as_str(), self.dir.as_deref()) { + let start_dir = match (default_dir.as_str(), resolved.dir.as_deref()) { (".", None) => Some(cwd), ("$HOME", None) => None, (d, None) => Some(String::from(d)), @@ -251,7 +273,7 @@ impl Attach { client .write_connect_header(ConnectHeader::Attach(AttachHeader { - name: String::from(name), + name: resolved.session_name.clone(), local_tty_size: tty_size, local_env: local_env_keys .into_iter() @@ -261,8 +283,9 @@ impl Attach { }) .collect::>(), ttl_secs: self.ttl.map(|d| d.as_secs()), - cmd: self.cmd.clone(), + cmd: resolved.cmd.clone(), dir: start_dir, + start_cmd: resolved.start_cmd.clone(), })) .context("writing attach header")?; @@ -283,16 +306,20 @@ impl Attach { for warning in warnings.into_iter() { eprintln!("shpool: warn: {warning}"); } - info!("attached to an existing session: '{}'", name); + info!("attached to an existing session: '{}'", resolved.session_name); } Created { warnings } => { for warning in warnings.into_iter() { eprintln!("shpool: warn: {warning}"); } - info!("created a new session: '{}'", name); + info!("created a new session: '{}'", resolved.session_name); } UnexpectedError(err) => { - return Err(anyhow!("BUG: unexpected error attaching to '{}': {}", name, err)); + return Err(anyhow!( + "BUG: unexpected error attaching to '{}': {}", + resolved.session_name, + err + )); } } } @@ -347,6 +374,31 @@ impl Attach { } } +struct Templates { + session_name: template::Template, + cmd: Option, + dir: Option, + start_cmd: Option, +} + +struct ResolvedTemplates { + session_name: String, + cmd: Option, + dir: Option, + start_cmd: Option, +} + +impl Templates { + fn apply(&self, var_map: &HashMap) -> ResolvedTemplates { + ResolvedTemplates { + session_name: self.session_name.apply(var_map), + cmd: self.cmd.as_ref().map(|t| t.apply(var_map)), + dir: self.dir.as_ref().map(|t| t.apply(var_map)), + start_cmd: self.start_cmd.as_ref().map(|t| t.apply(var_map)), + } + } +} + #[derive(Debug)] struct BusyError; impl fmt::Display for BusyError { diff --git a/libshpool/src/daemon/mod.rs b/libshpool/src/daemon/mod.rs index 7b52ec71..27e2368a 100644 --- a/libshpool/src/daemon/mod.rs +++ b/libshpool/src/daemon/mod.rs @@ -23,9 +23,9 @@ mod etc_environment; mod exit_notify; pub mod keybindings; mod pager; -mod prompt; mod server; mod shell; +mod shell_inject; mod show_motd; mod signals; mod systemd; diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs index 5c8d9cfc..536deddf 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -48,8 +48,8 @@ use crate::{ config::MotdDisplayMode, consts, daemon::{ - etc_environment, exit_notify::ExitNotifier, hooks, pager, pager::PagerError, prompt, shell, - show_motd, ttl_reaper, + etc_environment, exit_notify::ExitNotifier, hooks, pager, pager::PagerError, shell, + shell_inject, show_motd, ttl_reaper, }, protocol, test_hooks, tty, user, }; @@ -1038,8 +1038,10 @@ impl Server { let prompt_prefix_is_blank = self.config.get().prompt_prefix.as_ref().map(|p| p.is_empty()).unwrap_or(false); - let supports_sentinels = - header.cmd.is_none() && !prompt_prefix_is_blank && !does_not_support_sentinels(&shell); + let supports_sentinels = header.cmd.is_none() + && (header.start_cmd.as_ref().map(|c| !c.is_empty()).unwrap_or(false) + || !prompt_prefix_is_blank) + && !does_not_support_sentinels(&shell); info!("supports_sentianls={}", supports_sentinels); // Inject the prompt prefix, if any. For custom commands, avoid doing this @@ -1053,7 +1055,12 @@ impl Server { .prompt_prefix .clone() .unwrap_or(String::from(DEFAULT_PROMPT_PREFIX)); - if let Err(err) = prompt::maybe_inject_prefix(&mut fork, &prompt_prefix, &header.name) { + if let Err(err) = shell_inject::maybe_setup( + &mut fork, + &prompt_prefix, + header.start_cmd.as_ref().map(|c| c.as_ref()).unwrap_or(""), + &header.name, + ) { warn!("issue injecting prefix: {:?}", err); } } diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index c4062949..cc3ab283 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -34,7 +34,9 @@ use tracing::{debug, error, info, instrument, span, trace, warn, Level}; use crate::{ common, consts, - daemon::{config, exit_notify::ExitNotifier, keybindings, pager::PagerCtl, prompt, show_motd}, + daemon::{ + config, exit_notify::ExitNotifier, keybindings, pager::PagerCtl, shell_inject, show_motd, + }, protocol, protocol::ChunkExt as _, session_restore, test_hooks, @@ -225,7 +227,8 @@ impl SessionInner { args: ShellToClientArgs, ) -> anyhow::Result>> { let term_db = Arc::clone(&self.term_db); - let mut prompt_sentinel_scanner = prompt::SentinelScanner::new(consts::PROMPT_SENTINEL); + let mut prompt_sentinel_scanner = + shell_inject::SentinelScanner::new(consts::PROMPT_SENTINEL); // We only scan for the prompt sentinel if the user has not set up a // custom command or blanked out the prompt_prefix config option. diff --git a/libshpool/src/daemon/prompt.rs b/libshpool/src/daemon/shell_inject.rs similarity index 94% rename from libshpool/src/daemon/prompt.rs rename to libshpool/src/daemon/shell_inject.rs index bc52f39e..7cd90d02 100644 --- a/libshpool/src/daemon/prompt.rs +++ b/libshpool/src/daemon/shell_inject.rs @@ -47,15 +47,17 @@ enum KnownShell { Fish, } -/// Inject the given prefix into the given shell subprocess, using -/// the shell path in `shell` to decide the right way to go about +/// Inject the given prefix and startup cmdn into the given shell subprocess, +/// using the shell path in `shell` to decide the right way to go about /// injecting the prefix. /// -/// If the prefix is blank, this is a noop. +/// If either the prefix or startup cmd are blank, we do nothing for that +/// option. #[instrument(skip_all)] -pub fn maybe_inject_prefix( +pub fn maybe_setup( pty_master: &mut shpool_pty::fork::Fork, prompt_prefix: &str, + start_cmd: &str, session_name: &str, ) -> anyhow::Result<()> { let shell_pid = pty_master.child_pid().ok_or(anyhow!("no child pid"))?; @@ -117,6 +119,12 @@ pub fn maybe_inject_prefix( } }; + if !start_cmd.is_empty() { + script.push('\n'); + script.push_str(start_cmd); + script.push('\n'); + } + // With this magic env var set, `shpool daemon` will just // print the prompt sentinel and immediately exit. We do // this rather than `echo $PROMPT_SENTINEL` because different @@ -127,7 +135,7 @@ pub fn maybe_inject_prefix( let sentinel_cmd = format!("\n {}=prompt {} daemon\n", SENTINEL_FLAG_VAR, exe_path); script.push_str(sentinel_cmd.as_str()); - debug!("injecting prefix script '{}'", script); + debug!("injecting shell startup script '{}'", script); pty_master.write_all(script.as_bytes()).context("running prefix script")?; Ok(()) diff --git a/libshpool/src/lib.rs b/libshpool/src/lib.rs index aad548d6..8f01b552 100644 --- a/libshpool/src/lib.rs +++ b/libshpool/src/lib.rs @@ -168,7 +168,30 @@ pass to the binary using the shell-words crate." $HOME by default. Use '.' for pwd." )] dir: Option, - #[clap(help = "The name of the shell session to create or attach to")] + #[clap( + short, + long, + long_help = "A command to run in the shell immediately on startup + +This differs from the --cmd flag in that it is run +directly in the shell rather than replacing the shell. Think of +it as running `source cmd` rather than `exec cmd`. The main +usecase is to be able to automatically enter some useful context +such as a particular directory with a python virtual environment +already set up for example. + +--start-cmd supports templates in the same way that session names do, +so you can parameterize your command with shpool variables. For example, +you could do --start-cmd 'cd /workspace/path/prefix/{workspace}' to have +your session start in your workspace root depending on a 'workspace' var. +See 'shpool help var' for more details." + )] + start_cmd: Option, + #[clap(long_help = "The name of the shell session to create or attach to + +Session names support templates, and if a session name changes based on a +variable change, 'shpool attach' will automatically hang up and re-dial the +new session name. See 'shpool help var' for more details.")] name: String, }, @@ -425,8 +448,8 @@ pub fn run(args: Args, hooks: Option>) -> an log_level_handle, socket, ), - Commands::Attach { force, background, ttl, cmd, dir, name } => { - attach::run(config_manager, name, force, background, ttl, cmd, dir, socket) + Commands::Attach { force, background, ttl, cmd, dir, start_cmd, name } => { + attach::run(socket, config_manager, name, force, background, ttl, cmd, dir, start_cmd) } Commands::Detach { sessions } => detach::run(sessions, socket), Commands::Kill { sessions } => kill::run(sessions, socket), diff --git a/shpool-protocol/src/lib.rs b/shpool-protocol/src/lib.rs index 29fb62a4..49683ae9 100644 --- a/shpool-protocol/src/lib.rs +++ b/shpool-protocol/src/lib.rs @@ -232,6 +232,16 @@ pub struct AttachHeader { /// should be used. #[serde(default)] pub dir: Option, + /// If specified, shpool will inject the given command into the shell + /// when it first starts up. This option is ignored for reattaches. + /// Note that this differs from the cmd option in that it is run + /// directly in the shell rather than replacing the shell. Think of + /// it as running `source cmd` rather than `exec cmd`. The main + /// usecase is to be able to automatically enter some useful context + /// such as a particular directory with a python virtual environment + /// already set up for example. + #[serde(default)] + pub start_cmd: Option, } impl AttachHeader { diff --git a/shpool/tests/attach.rs b/shpool/tests/attach.rs index 302d6c78..0b5ae401 100644 --- a/shpool/tests/attach.rs +++ b/shpool/tests/attach.rs @@ -1870,3 +1870,105 @@ fn templated_session_name_no_switch_on_unrelated_var() -> anyhow::Result<()> { Ok(()) } + +#[test] +#[timeout(30000)] +fn start_cmd() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + start_cmd: Some(String::from("export STARTUP_CMD_RAN=true")), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("echo $STARTUP_CMD_RAN")?; + line_matcher.scan_until_re("true$")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn start_cmd_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + daemon_proc.var_set("myvar", "myval")?; + + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + start_cmd: Some(String::from("export STARTUP_CMD_VAR={myvar}")), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("echo $STARTUP_CMD_VAR")?; + line_matcher.scan_until_re("myval$")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn custom_cmd_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + daemon_proc.var_set("myarg", "templated_arg")?; + + let script = support::testdata_file("echo_stop.sh"); + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + cmd: Some(format!("{} {{myarg}}", script.into_os_string().into_string().unwrap())), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + // the script first echos the arg we gave it + line_matcher.scan_until_re("templated_arg$")?; + + attach_proc.run_cmd("stop")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn dir_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + let target_dir = daemon_proc.tmp_dir.path().join("templated_dir"); + fs::create_dir(&target_dir)?; + + daemon_proc.var_set("mydir", target_dir.to_str().unwrap())?; + + let mut attach_proc = daemon_proc + .attach("sh1", AttachArgs { dir: Some(String::from("{mydir}")), ..Default::default() }) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("pwd")?; + let expected_path = target_dir.to_str().unwrap(); + line_matcher.scan_until_re(&format!("{}$", regex::escape(expected_path)))?; + + Ok(()) +} diff --git a/shpool/tests/support/daemon.rs b/shpool/tests/support/daemon.rs index 102761b2..df768e33 100644 --- a/shpool/tests/support/daemon.rs +++ b/shpool/tests/support/daemon.rs @@ -54,6 +54,7 @@ pub struct AttachArgs { pub ttl: Option, pub cmd: Option, pub dir: Option, + pub start_cmd: Option, pub null_stdin: bool, } @@ -348,6 +349,10 @@ impl Proc { cmd.arg("--dir"); cmd.arg(dir); } + if let Some(start_cmd) = &args.start_cmd { + cmd.arg("--start-cmd"); + cmd.arg(start_cmd); + } let proc = cmd.arg(name).spawn().context(format!("spawning attach proc for {name}"))?; let events = Events::new(&test_hook_socket_path)?;