Wednesday, July 27, 2022

[SOLVED] Send SIGINT to a process by sending ctrl-c to stdin

Issue

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.


Solution

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)


Answered By - hansaplast
Answer Checked By - David Marino (WPSolving Volunteer)