4

I'm looking for a way to mimick a terminal for some automated testing: i.e. start a process and then interact with it via sending data to stdin and reading from stdout. E.g. sending some lines of input to stdin including ctrl-c and ctrl-\ which should result in sending signals to the process.

Using std::process::Commannd I'm able to send input to e.g. cat and I'm also seeing its output on stdout, but sending ctrl-c (as I understand that is 3) does not cause SIGINT sent to the shell. E.g. this program should terminate:

use std::process::{Command, Stdio};
use std::io::Write;

fn main() {
    let mut child = Command::new("sh")
        .arg("-c").arg("-i").arg("cat")
        .stdin(Stdio::piped())
        .spawn().unwrap();
    let mut stdin = child.stdin.take().unwrap();
    stdin.write(&[3]).expect("cannot send ctrl-c");
    child.wait();
}

I suspect the issue is that sending ctrl-c needs the some tty and via sh -i it's only in "interactive mode".

Do I need to go full fledged and use e.g. termion or ncurses?

Update: I confused shell and terminal in the original question. I cleared this up now. Also I mentioned ssh which should have been sh.

c z
  • 7,726
  • 3
  • 46
  • 59
hansaplast
  • 11,007
  • 2
  • 61
  • 75
  • 1
    If you press Ctrl-C those key presses never make it to the application. They're handled by the terminal, which responds to it by sending SIGINT to the process. So you want to send SIGINT to the process. – sepp2k May 04 '17 at 17:35
  • https://stackoverflow.com/questions/6108953/how-does-ctrl-c-terminate-a-child-process – Josh Lee May 04 '17 at 17:35
  • @sepp2k thanks for the question. I was aware that the shell transforms ctrl-c to SIGINT, but somehow forgot to add this bit in the question. The question should be clearer now. I also added the `-i` option, which should run sh in interactive mode but it still doesn't work – hansaplast May 04 '17 at 19:27
  • 2
    @hansaplast It's not the shell that handles Ctrl-C (how could it - once the application has been started, the shell isn't in control anymore), it's the terminal. So there's no point in you going through the shell instead of invoking `cat` directly and there's no way that sending Ctrl-C to the application would do anything. Instead you should send SIGINT to the process directly. – sepp2k May 04 '17 at 19:45
  • @sepp2k thanks for explaining. Indeed I confused terminal and shell. Then my suspicion is right that the `tty` is missing. In the end I want to build something with which I can test my rust binaries via "documentation tests" but instead of rust code I want shell interaction, including ctrl-c, and I explicitely want to test the interaction between the binary and the terminal. I'm currently looking into `termion`, that looks like the easiest way currently – hansaplast May 04 '17 at 20:07
  • if you wouldn't mind me asking for clarification, do you mean shell as `sh` and process is `cat`? I know all of them are processes, but I'm a bit confuse by their usage. – nate May 06 '17 at 18:15

4 Answers4

4

The simplest way is to directly send the SIGINT signal to the child process. This can be done easily using nix's signal::kill function:

// add `nix = "0.15.0"` to your Cargo.toml
use std::process::{Command, Stdio};
use std::io::Write;

fn main() {
    // spawn child process
    let mut child = Command::new("cat")
        .stdin(Stdio::piped())
        .spawn().unwrap();

    // send "echo\n" to child's stdin
    let mut stdin = child.stdin.take().unwrap();
    writeln!(stdin, "echo");

    // sleep a bit so that child can process the input
    std::thread::sleep(std::time::Duration::from_millis(500));

    // send SIGINT to the child
    nix::sys::signal::kill(
        nix::unistd::Pid::from_raw(child.id() as i32), 
        nix::sys::signal::Signal::SIGINT
    ).expect("cannot send ctrl-c");

    // wait for child to terminate
    child.wait().unwrap();
}

You should be able to send all kinds of signals using this method. For more advanced "interactivity" (e.g. child programs like vi that query terminal size) you'd need to create a pseudoterminal like @hansaplast did in his solution.

Felix
  • 6,131
  • 4
  • 24
  • 44
2

After a lot of research I figured out it's not too much work to do the pty fork myself. There's pty-rs, but it has bugs and seems unmaintained.

The following code needs pty module of nix which is not yet on crates.io, so Cargo.toml needs this for now:

[dependencies]
nix = {git = "https://github.com/nix-rust/nix.git"}

The following code runs cat in a tty and then writes/reads from it and sends Ctrl-C (3):

extern crate nix;

use std::path::Path;
use nix::pty::{posix_openpt, grantpt, unlockpt, ptsname};
use nix::fcntl::{O_RDWR, open};
use nix::sys::stat;
use nix::unistd::{fork, ForkResult, setsid, dup2};
use nix::libc::{STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO};
use std::os::unix::io::{AsRawFd, FromRawFd};
use std::io::prelude::*;
use std::io::{BufReader, LineWriter};


fn run() -> std::io::Result<()> {
    // Open a new PTY master
    let master_fd = posix_openpt(O_RDWR)?;

    // Allow a slave to be generated for it
    grantpt(&master_fd)?;
    unlockpt(&master_fd)?;

    // Get the name of the slave
    let slave_name = ptsname(&master_fd)?;

    match fork() {
        Ok(ForkResult::Child) => {
            setsid()?; // create new session with child as session leader
            let slave_fd = open(Path::new(&slave_name), O_RDWR, stat::Mode::empty())?;

            // assign stdin, stdout, stderr to the tty, just like a terminal does
            dup2(slave_fd, STDIN_FILENO)?;
            dup2(slave_fd, STDOUT_FILENO)?;
            dup2(slave_fd, STDERR_FILENO)?;
            std::process::Command::new("cat").status()?;
        }
        Ok(ForkResult::Parent { child: _ }) => {
            let f = unsafe { std::fs::File::from_raw_fd(master_fd.as_raw_fd()) };
            let mut reader = BufReader::new(&f);
            let mut writer = LineWriter::new(&f);

            writer.write_all(b"hello world\n")?;
            let mut s = String::new();
            reader.read_line(&mut s)?; // what we just wrote in
            reader.read_line(&mut s)?; // what cat wrote out
            writer.write(&[3])?; // send ^C
            writer.flush()?;
            let mut buf = [0; 2]; // needs bytewise read as ^C has no newline
            reader.read(&mut buf)?;
            s += &String::from_utf8_lossy(&buf).to_string();
            println!("{}", s);
            println!("cat exit code: {:?}", wait::wait()?); // make sure cat really exited
        }
        Err(_) => println!("error"),
    }
    Ok(())
}

fn main() {
    run().expect("could not execute command");
}

Output:

hello world
hello world
^C
cat exit code: Signaled(2906, SIGINT, false)
hansaplast
  • 11,007
  • 2
  • 61
  • 75
  • use nix::fcntl::{O_RDWR,open}; | ^^^^^^ no `O_RDWR` in `fcntl` – don bright Jul 01 '18 at 01:38
  • I've updated the code to work with the current version of `nix` (0.15.0). Code is on github: https://gist.github.com/fkohlgrueber/fc2bc9c3753ccc4a03d80d7f6c9bbcf0 – Felix Oct 18 '19 at 10:16
1

Try adding -t option TWICE to force pseudo-tty allocation. I.e.

klar (16:14) ~>echo foo | ssh user@host.ssh.com tty
not a tty
klar (16:14) ~>echo foo | ssh -t -t user@host.ssh.com tty
/dev/pts/0

When you have a pseudo-tty, I think it should convert that to SIGINT as you wanted to do.

In your simple example, you could also just close stdin after the write, in which case the server should exit. For this particular case it would be more elegant and probably more reliable.

  • but first I want to use `cat` (or some other command) and not `ssh`, and second I want to control the process with rust. IMO I really need a tty fork, otherwise Ctrl-C and the like would never work, see my answer below – hansaplast Jun 01 '17 at 06:55
0

Solution without using a crate

Now that you are spawning a command in Rust, you might as well spawn another to send SIGINT to it. That command is kill.

So, you can do this:

use std::process::{Command, Stdio};
use std::io::{Result, Write};

fn main() -> Result<()> {
    let mut child = Command::new("sh")
        .arg("-c").arg("-i").arg("cat")
        .stdin(Stdio::piped())
        .spawn()?;
    let mut stdin = child.stdin.take().unwrap();

    let mut kill = Command::new("kill")
        .arg(child.id().to_string())
        .spawn()?;
    kill.wait()
}
StevenHe
  • 262
  • 4
  • 4