diff --git a/Cargo.lock b/Cargo.lock
index f804fed..766934a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -869,6 +869,7 @@ dependencies = [
  "clap_mangen",
  "dns-lookup",
  "libc",
+ "nix",
  "phf",
  "phf_codegen",
  "pretty_assertions",
@@ -884,6 +885,7 @@ dependencies = [
  "uu_lsmem",
  "uu_mountpoint",
  "uu_rev",
+ "uu_setsid",
  "uucore",
  "xattr",
 ]
@@ -942,6 +944,15 @@ dependencies = [
  "uucore",
 ]
 
+[[package]]
+name = "uu_setsid"
+version = "0.0.1"
+dependencies = [
+ "clap",
+ "libc",
+ "uucore",
+]
+
 [[package]]
 name = "uucore"
 version = "0.0.27"
diff --git a/Cargo.toml b/Cargo.toml
index 4d7050e..d91e4aa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,6 +31,7 @@ feat_common_core = [
   "lsmem",
   "ctrlaltdel",
   "rev",
+  "setsid",
   "last"
 ]
 
@@ -46,6 +47,7 @@ phf = "0.11.2"
 phf_codegen = "0.11.2"
 textwrap = { version = "0.16.0", features = ["terminal_size"] }
 xattr = "1.3.1"
+nix = { version = "0.28", default-features = false }
 tempfile = "3.9.0"
 rand = { version = "0.8", features = ["small_rng"] }
 serde = { version = "1.0", features = ["derive"] }
@@ -68,6 +70,7 @@ lsmem = { optional = true, version = "0.0.1", package = "uu_lsmem", path = "src/
 mountpoint = { optional = true, version = "0.0.1", package = "uu_mountpoint", path = "src/uu/mountpoint" }
 ctrlaltdel = { optional = true, version = "0.0.1", package = "uu_ctrlaltdel", path = "src/uu/ctrlaltdel" }
 rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" }
+setsid = { optional = true, version = "0.0.1", package = "uu_setsid", path ="src/uu/setsid" }
 last = { optional = true, version = "0.0.1", package = "uu_last", path = "src/uu/last" }
 
 [dev-dependencies]
@@ -80,6 +83,7 @@ uucore = { workspace = true, features = ["entries", "process", "signals"] }
 
 [target.'cfg(unix)'.dev-dependencies]
 xattr = { workspace = true }
+nix = { workspace = true, features = ["term"] }
 
 [target.'cfg(any(target_os = "linux", target_os = "android"))'.dev-dependencies]
 procfs = { version = "0.17", default-features = false }
diff --git a/src/uu/setsid/Cargo.toml b/src/uu/setsid/Cargo.toml
new file mode 100644
index 0000000..9889d5c
--- /dev/null
+++ b/src/uu/setsid/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "uu_setsid"
+version = "0.0.1"
+edition = "2021"
+
+[dependencies]
+uucore = { workspace = true }
+libc = { workspace = true }
+clap = { workspace = true }
+
+[lib]
+path = "src/setsid.rs"
+
+[[bin]]
+name = "setsid"
+path = "src/main.rs"
diff --git a/src/uu/setsid/setsid.md b/src/uu/setsid/setsid.md
new file mode 100644
index 0000000..dbcb937
--- /dev/null
+++ b/src/uu/setsid/setsid.md
@@ -0,0 +1,7 @@
+# setsid
+
+```
+setsid [options] <program> [argument ...]
+```
+
+Run a program in a new session.
diff --git a/src/uu/setsid/src/main.rs b/src/uu/setsid/src/main.rs
new file mode 100644
index 0000000..916937a
--- /dev/null
+++ b/src/uu/setsid/src/main.rs
@@ -0,0 +1 @@
+uucore::bin!(uu_setsid);
diff --git a/src/uu/setsid/src/setsid.rs b/src/uu/setsid/src/setsid.rs
new file mode 100644
index 0000000..a4d99e5
--- /dev/null
+++ b/src/uu/setsid/src/setsid.rs
@@ -0,0 +1,199 @@
+// This file is part of the uutils util-linux package.
+//
+// For the full copyright and license information, please view the LICENSE
+// file that was distributed with this source code.
+
+use clap::builder::ValueParser;
+use clap::{crate_version, Command};
+use clap::{Arg, ArgAction};
+use uucore::{error::UResult, format_usage, help_about, help_usage};
+
+const ABOUT: &str = help_about!("setsid.md");
+const USAGE: &str = help_usage!("setsid.md");
+
+#[cfg(target_family = "unix")]
+mod unix {
+    pub use std::ffi::{OsStr, OsString};
+    pub use std::os::unix::process::CommandExt;
+    pub use std::{io, process};
+    pub use uucore::error::{FromIo, UIoError, UResult};
+
+    // The promise made by setsid(1) is that it forks if the process
+    // is already a group leader, not session leader.
+    pub fn already_group_leader() -> bool {
+        let leader = unsafe { libc::getpgrp() };
+        leader == process::id() as i32
+    }
+
+    pub fn report_failure_to_exec(error: io::Error, executable: &OsStr, set_error: bool) {
+        let kind = error.kind();
+
+        // FIXME: POSIX wants certain exit statuses for specific errors, should
+        // these be handled by uucore::error? We should be able to just return
+        // the UError here.
+        uucore::show_error!(
+            "failed to execute {}: {}",
+            executable.to_string_lossy(),
+            UIoError::from(error)
+        );
+
+        if set_error {
+            if kind == io::ErrorKind::NotFound {
+                uucore::error::set_exit_code(127);
+            } else if kind == io::ErrorKind::PermissionDenied {
+                uucore::error::set_exit_code(126);
+            }
+        }
+    }
+
+    // This function will be potentially called after a fork(), so what it can do
+    // is quite restricted. This is the meat of the program.
+    pub fn prepare_child(take_controlling_tty: bool) -> io::Result<()> {
+        // SAFETY: this is effectively a wrapper to the setsid syscall.
+        let pid = unsafe { libc::setsid() };
+
+        // We fork if we are already a group leader, so an error
+        // here should be impossible.
+        assert_eq!(pid, process::id() as i32);
+
+        // On some platforms (i.e. aarch64 Linux) TIOCSCTTY is the same type as the second argument,
+        // but on some it is u64, while the expected type is u32.
+        // SAFETY: the ioctl should not make any changes to memory, basically a wrapper
+        // to the syscall.
+        #[allow(clippy::useless_conversion)]
+        if take_controlling_tty && unsafe { libc::ioctl(0, libc::TIOCSCTTY.into(), 1) } < 0 {
+            // This is unfortunate, but we are bound by the Result type pre_exec requires,
+            // as well as the limitations imposed by this being executed post-fork().
+            // Ideally we would return an io::Error of the Other kind so that we could handle
+            // everything at the same place, but that would require an allocation.
+            uucore::show_error!(
+                "failed to set the controlling terminal: {}",
+                UIoError::from(io::Error::last_os_error())
+            );
+
+            // SAFETY: this is actually safer than calling process::exit(), as that may
+            // allocate, which is not safe post-fork.
+            unsafe { libc::_exit(1) };
+        }
+        Ok(())
+    }
+
+    pub fn spawn_command(
+        mut to_run: process::Command,
+        executable: &OsStr,
+        wait_child: bool,
+    ) -> UResult<()> {
+        let mut child = match to_run.spawn() {
+            Ok(child) => child,
+            Err(error) => {
+                report_failure_to_exec(error, executable, wait_child);
+                return Ok(());
+            }
+        };
+
+        if !wait_child {
+            return Ok(());
+        }
+
+        match child.wait() {
+            Ok(status) => {
+                uucore::error::set_exit_code(status.code().unwrap());
+                Ok(())
+            }
+            Err(error) => {
+                Err(error.map_err_context(|| format!("failed to wait on PID {}", child.id())))
+            }
+        }
+    }
+}
+
+#[cfg(target_family = "unix")]
+use unix::*;
+
+#[cfg(target_family = "unix")]
+#[uucore::main]
+pub fn uumain(args: impl uucore::Args) -> UResult<()> {
+    let matches: clap::ArgMatches = uu_app().try_get_matches_from(args)?;
+
+    let force_fork = matches.get_flag("fork");
+    let wait_child = matches.get_flag("wait");
+    let take_controlling_tty = matches.get_flag("ctty");
+
+    let command: Vec<_> = match matches.get_many::<OsString>("command") {
+        Some(v) => v.collect(),
+        None => return Err(uucore::error::USimpleError::new(1, "no command specified")),
+    };
+
+    // We know we have at least one item, as none was
+    // handled as an error on the match above.
+    let executable = command[0];
+    let arguments = command.get(1..).unwrap_or(&[]);
+
+    let mut to_run = process::Command::new(executable);
+    to_run.args(arguments.iter());
+
+    // SAFETY: pre_exec() happens post-fork, so the process can potentially
+    // be in a broken state; allocations are not safe, and we should exit
+    // as soon as possible if we cannot go ahead.
+    unsafe {
+        to_run.pre_exec(move || prepare_child(take_controlling_tty));
+    };
+
+    if force_fork || already_group_leader() {
+        spawn_command(to_run, executable, wait_child)?;
+    } else {
+        let error = to_run.exec();
+        report_failure_to_exec(error, executable, true);
+    }
+
+    Ok(())
+}
+
+#[cfg(not(target_family = "unix"))]
+#[uucore::main]
+pub fn uumain(args: impl uucore::Args) -> UResult<()> {
+    let _matches: clap::ArgMatches = uu_app().try_get_matches_from(args)?;
+
+    Err(uucore::error::USimpleError::new(
+        1,
+        "`setsid` is unavailable on non-UNIX-like platforms.",
+    ))
+}
+
+pub fn uu_app() -> Command {
+    Command::new(uucore::util_name())
+        .version(crate_version!())
+        .about(ABOUT)
+        .override_usage(format_usage(USAGE))
+        .infer_long_args(true)
+        .arg(
+            Arg::new("ctty")
+                .short('c')
+                .action(ArgAction::SetTrue)
+                .value_parser(ValueParser::bool())
+                .help("Take the current controlling terminal"),
+        )
+        .arg(
+            Arg::new("fork")
+                .short('f')
+                .action(ArgAction::SetTrue)
+                .value_parser(ValueParser::bool())
+                .long_help("Always create a new process. By default this is only done if we are already a process group lead."),
+        )
+        .arg(
+            Arg::new("wait")
+                .short('w')
+                .action(ArgAction::SetTrue)
+                .value_parser(ValueParser::bool())
+                .help("Wait for the command to finish and exit with its exit code."),
+        )
+        .arg(
+            Arg::new("command")
+                .help("Program to be executed, followed by its arguments")
+                .index(1)
+                .action(ArgAction::Set)
+                .trailing_var_arg(true)
+                .value_parser(ValueParser::os_string())
+                .num_args(1..),
+        )
+}
diff --git a/tests/by-util/test_setsid.rs b/tests/by-util/test_setsid.rs
new file mode 100644
index 0000000..cf3d159
--- /dev/null
+++ b/tests/by-util/test_setsid.rs
@@ -0,0 +1,174 @@
+// This file is part of the uutils util-linux package.
+//
+// For the full copyright and license information, please view the LICENSE
+// file that was distributed with this source code.
+
+#[cfg(target_family = "unix")]
+mod unix {
+    use crate::common::util::{TestScenario, UCommand, TESTS_BINARY};
+
+    #[test]
+    fn test_invalid_arg() {
+        new_ucmd!().arg("--definitely-invalid").fails().code_is(1);
+
+        new_ucmd!()
+            .arg("-f")
+            .fails()
+            .code_is(1)
+            .stderr_is("setsid: no command specified\n");
+    }
+
+    #[test]
+    fn fork_isolates_child_exit_code() {
+        new_ucmd!()
+            .arg("-f")
+            .arg("/usr/bin/false")
+            .succeeds()
+            .no_output();
+    }
+
+    #[test]
+    fn non_fork_returns_child_exit_code() {
+        new_ucmd!()
+            .arg("/usr/bin/false")
+            .fails()
+            .code_is(1)
+            .no_output();
+    }
+
+    #[test]
+    fn fork_wait_returns_child_exit_code() {
+        new_ucmd!()
+            .arg("-f")
+            .arg("-w")
+            .arg("/usr/bin/false")
+            .fails()
+            .code_is(1)
+            .no_output();
+    }
+
+    #[test]
+    fn non_fork_returns_not_found_error() {
+        new_ucmd!()
+        .arg("/usr/bin/this-tool-does-not-exist-hopefully")
+        .fails()
+        .code_is(127)
+        .stderr_is("setsid: failed to execute /usr/bin/this-tool-does-not-exist-hopefully: No such file or directory\n");
+    }
+
+    #[test]
+    fn non_fork_on_non_executable_returns_permission_denied_error() {
+        new_ucmd!()
+            .arg("/etc/passwd")
+            .fails()
+            .code_is(126)
+            .stderr_is("setsid: failed to execute /etc/passwd: Permission denied\n");
+    }
+
+    #[test]
+    fn fork_isolates_not_found_error() {
+        new_ucmd!()
+            .arg("-f")
+            .arg("/usr/bin/this-tool-does-not-exist-hopefully")
+            .succeeds();
+        // no test for output, as it's a race whether the not found error gets printed
+        // quickly enough, potential flakyness
+    }
+
+    #[test]
+    fn unprivileged_user_cannot_steal_controlling_tty() {
+        let shell_cmd =
+            format!("{TESTS_BINARY} setsid -w -c {TESTS_BINARY} setsid -w -c /b/usrin/true");
+        UCommand::new()
+            .terminal_simulation(true)
+            .arg(&shell_cmd)
+            .fails()
+            .code_is(1)
+            .no_stdout()
+            .stderr_is("setsid: failed to set the controlling terminal: Permission denied\r\n");
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn unprivileged_user_can_take_new_controlling_tty() {
+        let shell_cmd = format!(
+            "/usr/bin/cat /proc/self/stat; {TESTS_BINARY} setsid -w -c /usr/bin/cat /proc/self/stat"
+        );
+
+        let cmd_result = UCommand::new()
+            .terminal_simulation(true)
+            .arg(&shell_cmd)
+            .succeeds();
+
+        let output = cmd_result.code_is(0).no_stderr().stdout_str();
+
+        // /proc/self/stat format has controlling terminal as the 7th
+        // space-separated item; if we managed to change the controlling
+        // terminal, we should see a difference there
+        let (before, after) = output
+            .split_once('\n')
+            .expect("expected 2 lines of output at least");
+        let before = before
+            .split_whitespace()
+            .nth(6)
+            .expect("unexpected stat format");
+        let after = after
+            .split_whitespace()
+            .nth(6)
+            .expect("unexpected stat format");
+
+        assert_ne!(before, after);
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn setsid_takes_session_leadership() {
+        let shell_cmd = format!(
+            "/usr/bin/cat /proc/self/stat; {TESTS_BINARY} setsid /usr/bin/cat /proc/self/stat"
+        );
+
+        let cmd_result = UCommand::new()
+            .terminal_simulation(true)
+            .arg(&shell_cmd)
+            .run();
+
+        let output = cmd_result.code_is(0).no_stderr().stdout_str();
+
+        // /proc/self/stat format has sessiion ID as the 6th space-separated
+        // item; if we managed to get session leadership, we should see a
+        // difference there...
+        let (before, after) = output
+            .split_once('\n')
+            .expect("expected 2 lines of output at least");
+        let before = before
+            .split_whitespace()
+            .nth(5)
+            .expect("unexpected stat format");
+        let after = after
+            .split_whitespace()
+            .nth(5)
+            .expect("unexpected stat format");
+
+        assert_ne!(before, after);
+
+        // ...and it should actually be the PID of our child! We take the child
+        // PID here to avoid differences in handling by different shells or
+        // distributions.
+        let pid = after.split_whitespace().next().unwrap();
+        assert_eq!(after, pid);
+    }
+}
+
+#[cfg(not(target_family = "unix"))]
+mod non_unix {
+    use crate::common::util::{TestScenario, UCommand};
+
+    #[test]
+    fn unsupported_platforms() {
+        new_ucmd!()
+            .arg("/usr/bin/true")
+            .fails()
+            .code_is(1)
+            .stderr_is("setsid: `setsid` is unavailable on non-UNIX-like platforms.\n");
+    }
+}
diff --git a/tests/common/util.rs b/tests/common/util.rs
index 4b66b85..0063454 100644
--- a/tests/common/util.rs
+++ b/tests/common/util.rs
@@ -3,10 +3,12 @@
 // For the full copyright and license information, please view the LICENSE
 // file that was distributed with this source code.
 
-//spell-checker: ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized
+//spell-checker: ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty winsize xpixel ypixel
 
 #![allow(dead_code, unexpected_cfgs)]
 
+#[cfg(unix)]
+use nix::pty::OpenptyResult;
 use pretty_assertions::assert_eq;
 #[cfg(any(target_os = "linux", target_os = "android"))]
 use rlimit::prlimit;
@@ -19,6 +21,8 @@ use std::ffi::{OsStr, OsString};
 use std::fs::{self, hard_link, remove_file, File, OpenOptions};
 use std::io::{self, BufWriter, Read, Result, Write};
 #[cfg(unix)]
+use std::os::fd::OwnedFd;
+#[cfg(unix)]
 use std::os::unix::fs::{symlink as symlink_dir, symlink as symlink_file, PermissionsExt};
 #[cfg(unix)]
 use std::os::unix::process::ExitStatusExt;
@@ -32,7 +36,7 @@ use std::rc::Rc;
 use std::sync::mpsc::{self, RecvTimeoutError};
 use std::thread::{sleep, JoinHandle};
 use std::time::{Duration, Instant};
-use std::{env, hint, thread};
+use std::{env, hint, mem, thread};
 use tempfile::{Builder, TempDir};
 
 static TESTS_DIR: &str = "tests";
@@ -44,6 +48,7 @@ static ALREADY_RUN: &str = " you have already run this UCommand, if you want to
 static MULTIPLE_STDIN_MEANINGLESS: &str = "Ucommand is designed around a typical use case of: provide args and input stream -> spawn process -> block until completion -> return output streams. For verifying that a particular section of the input stream is what causes a particular behavior, use the Command type directly.";
 
 static NO_STDIN_MEANINGLESS: &str = "Setting this flag has no effect if there is no stdin";
+static END_OF_TRANSMISSION_SEQUENCE: &[u8] = &[b'\n', 0x04];
 
 pub const TESTS_BINARY: &str = env!("CARGO_BIN_EXE_util-linux");
 pub const PATH: &str = env!("PATH");
@@ -385,7 +390,10 @@ impl CmdResult {
     pub fn success(&self) -> &Self {
         assert!(
             self.succeeded(),
-            "Command was expected to succeed.\nstdout = {}\n stderr = {}",
+            "Command was expected to succeed. Exit code: {}.\nstdout = {}\n stderr = {}",
+            self.exit_status()
+                .code()
+                .map_or("n/a".to_string(), |code| code.to_string()),
             self.stdout_str(),
             self.stderr_str()
         );
@@ -1218,6 +1226,10 @@ pub struct UCommand {
     limits: Vec<(rlimit::Resource, u64, u64)>,
     stderr_to_stdout: bool,
     timeout: Option<Duration>,
+    #[cfg(unix)]
+    terminal_simulation: bool,
+    #[cfg(unix)]
+    terminal_size: Option<libc::winsize>,
     tmpd: Option<Rc<TempDir>>, // drop last
 }
 
@@ -1396,6 +1408,68 @@ impl UCommand {
         self
     }
 
+    /// Set if process should be run in a simulated terminal
+    ///
+    /// This is useful to test behavior that is only active if [`stdout.is_terminal()`] is [`true`].
+    /// (unix: pty, windows: ConPTY[not yet supported])
+    #[cfg(unix)]
+    pub fn terminal_simulation(&mut self, enable: bool) -> &mut Self {
+        self.terminal_simulation = enable;
+        self
+    }
+
+    /// Set if process should be run in a simulated terminal with specific size
+    ///
+    /// This is useful to test behavior that is only active if [`stdout.is_terminal()`] is [`true`].
+    /// And the size of the terminal matters additionally.
+    #[cfg(unix)]
+    pub fn terminal_size(&mut self, win_size: libc::winsize) -> &mut Self {
+        self.terminal_simulation(true);
+        self.terminal_size = Some(win_size);
+        self
+    }
+
+    #[cfg(unix)]
+    fn read_from_pty(pty_fd: std::os::fd::OwnedFd, out: File) {
+        let read_file = std::fs::File::from(pty_fd);
+        let mut reader = std::io::BufReader::new(read_file);
+        let mut writer = std::io::BufWriter::new(out);
+        let result = std::io::copy(&mut reader, &mut writer);
+        match result {
+            Ok(_) => {}
+            // Input/output error (os error 5) is returned due to pipe closes. Buffer gets content anyway.
+            Err(e) if e.raw_os_error().unwrap_or_default() == 5 => {}
+            Err(e) => {
+                eprintln!("Unexpected error: {:?}", e);
+                panic!("error forwarding output of pty");
+            }
+        }
+    }
+
+    #[cfg(unix)]
+    fn spawn_reader_thread(
+        &self,
+        captured_output: Option<CapturedOutput>,
+        pty_fd_master: OwnedFd,
+        name: String,
+    ) -> Option<CapturedOutput> {
+        if let Some(mut captured_output_i) = captured_output {
+            let fd = captured_output_i.try_clone().unwrap();
+
+            let handle = std::thread::Builder::new()
+                .name(name)
+                .spawn(move || {
+                    Self::read_from_pty(pty_fd_master, fd);
+                })
+                .unwrap();
+
+            captured_output_i.reader_thread_handle = Some(handle);
+            Some(captured_output_i)
+        } else {
+            None
+        }
+    }
+
     /// Build the `std::process::Command` and apply the defaults on fields which were not specified
     /// by the user.
     ///
@@ -1415,7 +1489,14 @@ impl UCommand {
     /// * `stderr_to_stdout`: `false`
     /// * `bytes_into_stdin`: `None`
     /// * `limits`: `None`.
-    fn build(&mut self) -> (Command, Option<CapturedOutput>, Option<CapturedOutput>) {
+    fn build(
+        &mut self,
+    ) -> (
+        Command,
+        Option<CapturedOutput>,
+        Option<CapturedOutput>,
+        Option<File>,
+    ) {
         if self.bin_path.is_some() {
             if let Some(util_name) = &self.util_name {
                 self.args.push_front(util_name.into());
@@ -1494,6 +1575,10 @@ impl UCommand {
 
         let mut captured_stdout = None;
         let mut captured_stderr = None;
+        #[cfg(unix)]
+        let mut stdin_pty: Option<File> = None;
+        #[cfg(not(unix))]
+        let stdin_pty: Option<File> = None;
         if self.stderr_to_stdout {
             let mut output = CapturedOutput::default();
 
@@ -1527,7 +1612,39 @@ impl UCommand {
                 .stderr(stderr);
         };
 
-        (command, captured_stdout, captured_stderr)
+        #[cfg(unix)]
+        if self.terminal_simulation {
+            let terminal_size = self.terminal_size.unwrap_or(libc::winsize {
+                ws_col: 80,
+                ws_row: 30,
+                ws_xpixel: 80 * 8,
+                ws_ypixel: 30 * 10,
+            });
+
+            let OpenptyResult {
+                slave: pi_slave,
+                master: pi_master,
+            } = nix::pty::openpty(&terminal_size, None).unwrap();
+            let OpenptyResult {
+                slave: po_slave,
+                master: po_master,
+            } = nix::pty::openpty(&terminal_size, None).unwrap();
+            let OpenptyResult {
+                slave: pe_slave,
+                master: pe_master,
+            } = nix::pty::openpty(&terminal_size, None).unwrap();
+
+            stdin_pty = Some(File::from(pi_master));
+
+            captured_stdout =
+                self.spawn_reader_thread(captured_stdout, po_master, "stdout_reader".to_string());
+            captured_stderr =
+                self.spawn_reader_thread(captured_stderr, pe_master, "stderr_reader".to_string());
+
+            command.stdin(pi_slave).stdout(po_slave).stderr(pe_slave);
+        }
+
+        (command, captured_stdout, captured_stderr, stdin_pty)
     }
 
     /// Spawns the command, feeds the stdin if any, and returns the
@@ -1536,7 +1653,7 @@ impl UCommand {
         assert!(!self.has_run, "{}", ALREADY_RUN);
         self.has_run = true;
 
-        let (mut command, captured_stdout, captured_stderr) = self.build();
+        let (mut command, captured_stdout, captured_stderr, stdin_pty) = self.build();
         log_info("run", self.to_string());
 
         let child = command.spawn().unwrap();
@@ -1552,7 +1669,7 @@ impl UCommand {
             .unwrap();
         }
 
-        let mut child = UChild::from(self, child, captured_stdout, captured_stderr);
+        let mut child = UChild::from(self, child, captured_stdout, captured_stderr, stdin_pty);
 
         if let Some(input) = self.bytes_into_stdin.take() {
             child.pipe_in(input);
@@ -1617,6 +1734,7 @@ impl std::fmt::Display for UCommand {
 struct CapturedOutput {
     current_file: File,
     output: tempfile::NamedTempFile, // drop last
+    reader_thread_handle: Option<thread::JoinHandle<()>>,
 }
 
 impl CapturedOutput {
@@ -1625,6 +1743,7 @@ impl CapturedOutput {
         Self {
             current_file: output.reopen().unwrap(),
             output,
+            reader_thread_handle: None,
         }
     }
 
@@ -1701,6 +1820,7 @@ impl Default for CapturedOutput {
         Self {
             current_file: file.reopen().unwrap(),
             output: file,
+            reader_thread_handle: None,
         }
     }
 }
@@ -1834,6 +1954,7 @@ pub struct UChild {
     util_name: Option<String>,
     captured_stdout: Option<CapturedOutput>,
     captured_stderr: Option<CapturedOutput>,
+    stdin_pty: Option<File>,
     ignore_stdin_write_error: bool,
     stderr_to_stdout: bool,
     join_handle: Option<JoinHandle<io::Result<()>>>,
@@ -1847,6 +1968,7 @@ impl UChild {
         child: Child,
         captured_stdout: Option<CapturedOutput>,
         captured_stderr: Option<CapturedOutput>,
+        stdin_pty: Option<File>,
     ) -> Self {
         Self {
             raw: child,
@@ -1854,6 +1976,7 @@ impl UChild {
             util_name: ucommand.util_name.clone(),
             captured_stdout,
             captured_stderr,
+            stdin_pty,
             ignore_stdin_write_error: ucommand.ignore_stdin_write_error,
             stderr_to_stdout: ucommand.stderr_to_stdout,
             join_handle: None,
@@ -1994,11 +2117,19 @@ impl UChild {
     /// error.
     #[deprecated = "Please use wait() -> io::Result<CmdResult> instead."]
     pub fn wait_with_output(mut self) -> io::Result<Output> {
+        // some apps do not stop execution until their stdin gets closed.
+        // to prevent a endless waiting here, we close the stdin.
+        self.join(); // ensure that all pending async input is piped in
+        self.close_stdin();
+
         let output = if let Some(timeout) = self.timeout {
             let child = self.raw;
 
             let (sender, receiver) = mpsc::channel();
-            let handle = thread::spawn(move || sender.send(child.wait_with_output()));
+            let handle = thread::Builder::new()
+                .name("wait_with_output".to_string())
+                .spawn(move || sender.send(child.wait_with_output()))
+                .unwrap();
 
             match receiver.recv_timeout(timeout) {
                 Ok(result) => {
@@ -2030,9 +2161,15 @@ impl UChild {
         };
 
         if let Some(stdout) = self.captured_stdout.as_mut() {
+            if let Some(handle) = stdout.reader_thread_handle.take() {
+                handle.join().unwrap();
+            }
             output.stdout = stdout.output_bytes();
         }
         if let Some(stderr) = self.captured_stderr.as_mut() {
+            if let Some(handle) = stderr.reader_thread_handle.take() {
+                handle.join().unwrap();
+            }
             output.stderr = stderr.output_bytes();
         }
 
@@ -2194,6 +2331,29 @@ impl UChild {
         }
     }
 
+    fn access_stdin_as_writer<'a>(&'a mut self) -> Box<dyn Write + Send + 'a> {
+        if let Some(stdin_fd) = &self.stdin_pty {
+            Box::new(BufWriter::new(stdin_fd.try_clone().unwrap()))
+        } else {
+            let stdin: &mut std::process::ChildStdin = self.raw.stdin.as_mut().unwrap();
+            Box::new(BufWriter::new(stdin))
+        }
+    }
+
+    fn take_stdin_as_writer(&mut self) -> Box<dyn Write + Send> {
+        if let Some(stdin_fd) = mem::take(&mut self.stdin_pty) {
+            Box::new(BufWriter::new(stdin_fd))
+        } else {
+            let stdin = self
+                .raw
+                .stdin
+                .take()
+                .expect("Could not pipe into child process. Was it set to Stdio::null()?");
+
+            Box::new(BufWriter::new(stdin))
+        }
+    }
+
     /// Pipe data into [`Child`] stdin in a separate thread to avoid deadlocks.
     ///
     /// In contrast to [`UChild::write_in`], this method is designed to simulate a pipe on the
@@ -2215,24 +2375,24 @@ impl UChild {
     /// [`JoinHandle`]: std::thread::JoinHandle
     pub fn pipe_in<T: Into<Vec<u8>>>(&mut self, content: T) -> &mut Self {
         let ignore_stdin_write_error = self.ignore_stdin_write_error;
-        let content = content.into();
-        let stdin = self
-            .raw
-            .stdin
-            .take()
-            .expect("Could not pipe into child process. Was it set to Stdio::null()?");
+        let mut content: Vec<u8> = content.into();
+        if self.stdin_pty.is_some() {
+            content.append(&mut END_OF_TRANSMISSION_SEQUENCE.to_vec());
+        }
+        let mut writer = self.take_stdin_as_writer();
 
-        let join_handle = thread::spawn(move || {
-            let mut writer = BufWriter::new(stdin);
-
-            match writer.write_all(&content).and_then(|()| writer.flush()) {
-                Err(error) if !ignore_stdin_write_error => Err(io::Error::new(
-                    io::ErrorKind::Other,
-                    format!("failed to write to stdin of child: {error}"),
-                )),
-                Ok(()) | Err(_) => Ok(()),
-            }
-        });
+        let join_handle = std::thread::Builder::new()
+            .name("pipe_in".to_string())
+            .spawn(
+                move || match writer.write_all(&content).and_then(|()| writer.flush()) {
+                    Err(error) if !ignore_stdin_write_error => Err(io::Error::new(
+                        io::ErrorKind::Other,
+                        format!("failed to write to stdin of child: {error}"),
+                    )),
+                    Ok(()) | Err(_) => Ok(()),
+                },
+            )
+            .unwrap();
 
         self.join_handle = Some(join_handle);
         self
@@ -2275,10 +2435,11 @@ impl UChild {
     /// # Errors
     /// If [`ChildStdin::write_all`] or [`ChildStdin::flush`] returned an error
     pub fn try_write_in<T: Into<Vec<u8>>>(&mut self, data: T) -> io::Result<()> {
-        let stdin = self.raw.stdin.as_mut().unwrap();
+        let ignore_stdin_write_error = self.ignore_stdin_write_error;
+        let mut writer = self.access_stdin_as_writer();
 
-        match stdin.write_all(&data.into()).and_then(|()| stdin.flush()) {
-            Err(error) if !self.ignore_stdin_write_error => Err(io::Error::new(
+        match writer.write_all(&data.into()).and_then(|()| writer.flush()) {
+            Err(error) if !ignore_stdin_write_error => Err(io::Error::new(
                 io::ErrorKind::Other,
                 format!("failed to write to stdin of child: {error}"),
             )),
@@ -2315,6 +2476,11 @@ impl UChild {
     /// Note, this does not have any effect if using the [`UChild::pipe_in`] method.
     pub fn close_stdin(&mut self) -> &mut Self {
         self.raw.stdin.take();
+        if self.stdin_pty.is_some() {
+            // a pty can not be closed. We need to send a EOT:
+            let _ = self.try_write_in(END_OF_TRANSMISSION_SEQUENCE);
+            self.stdin_pty.take();
+        }
         self
     }
 }
@@ -3413,4 +3579,123 @@ mod tests {
         xattr::set(&file_path2, test_attr, test_value).unwrap();
         assert!(compare_xattrs(&file_path1, &file_path2));
     }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_false() {
+        let scene = TestScenario::new("util");
+
+        let out = scene.cmd("env").arg("sh").arg("is_atty.sh").succeeds();
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stdout()),
+            "stdin is not atty\nstdout is not atty\nstderr is not atty\n"
+        );
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stderr()),
+            "This is an error message.\n"
+        );
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_true() {
+        let scene = TestScenario::new("util");
+
+        let out = scene
+            .cmd("env")
+            .arg("sh")
+            .arg("is_atty.sh")
+            .terminal_simulation(true)
+            .succeeds();
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stdout()),
+            "stdin is atty\r\nstdout is atty\r\nstderr is atty\r\nterminal size: 30 80\r\n"
+        );
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stderr()),
+            "This is an error message.\r\n"
+        );
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_size_information() {
+        let scene = TestScenario::new("util");
+
+        let out = scene
+            .cmd("env")
+            .arg("sh")
+            .arg("is_atty.sh")
+            .terminal_size(libc::winsize {
+                ws_col: 40,
+                ws_row: 10,
+                ws_xpixel: 40 * 8,
+                ws_ypixel: 10 * 10,
+            })
+            .succeeds();
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stdout()),
+            "stdin is atty\r\nstdout is atty\r\nstderr is atty\r\nterminal size: 10 40\r\n"
+        );
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stderr()),
+            "This is an error message.\r\n"
+        );
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_pty_sends_eot_automatically() {
+        let scene = TestScenario::new("util");
+
+        let mut cmd = scene.cmd("env");
+        cmd.timeout(std::time::Duration::from_secs(10));
+        cmd.args(&["cat", "-"]);
+        cmd.terminal_simulation(true);
+        let child = cmd.run_no_wait();
+        let out = child.wait().unwrap(); // cat would block if there is no eot
+
+        std::assert_eq!(String::from_utf8_lossy(out.stderr()), "");
+        std::assert_eq!(String::from_utf8_lossy(out.stdout()), "\r\n");
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_pty_pipes_into_data_and_sends_eot_automatically() {
+        let scene = TestScenario::new("util");
+
+        let message = "Hello stdin forwarding!";
+
+        let mut cmd = scene.cmd("env");
+        cmd.args(&["cat", "-"]);
+        cmd.terminal_simulation(true);
+        cmd.pipe_in(message);
+        let child = cmd.run_no_wait();
+        let out = child.wait().unwrap();
+
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stdout()),
+            format!("{}\r\n", message)
+        );
+        std::assert_eq!(String::from_utf8_lossy(out.stderr()), "");
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn test_simulation_of_terminal_pty_write_in_data_and_sends_eot_automatically() {
+        let scene = TestScenario::new("util");
+
+        let mut cmd = scene.cmd("env");
+        cmd.args(&["cat", "-"]);
+        cmd.terminal_simulation(true);
+        let mut child = cmd.run_no_wait();
+        child.write_in("Hello stdin forwarding via write_in!");
+        let out = child.wait().unwrap();
+
+        std::assert_eq!(
+            String::from_utf8_lossy(out.stdout()),
+            "Hello stdin forwarding via write_in!\r\n"
+        );
+        std::assert_eq!(String::from_utf8_lossy(out.stderr()), "");
+    }
 }
diff --git a/tests/fixtures/util/is_atty.sh b/tests/fixtures/util/is_atty.sh
new file mode 100644
index 0000000..30f8caf
--- /dev/null
+++ b/tests/fixtures/util/is_atty.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+if [ -t 0 ] ; then
+    echo "stdin is atty"
+else
+    echo "stdin is not atty"
+fi
+
+if [ -t 1 ] ; then
+    echo "stdout is atty"
+else
+    echo "stdout is not atty"
+fi
+
+if [ -t 2 ] ; then
+    echo "stderr is atty"
+    echo "terminal size: $(stty size)"
+else
+    echo "stderr is not atty"
+fi
+
+>&2 echo "This is an error message."
+
+true
diff --git a/tests/tests.rs b/tests/tests.rs
index 57e9513..64cfc84 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -25,6 +25,10 @@ mod test_ctrlaltdel;
 #[path = "by-util/test_rev.rs"]
 mod test_rev;
 
+#[cfg(feature = "setsid")]
+#[path = "by-util/test_setsid.rs"]
+mod test_setsid;
+
 #[cfg(feature = "last")]
 #[path = "by-util/test_last.rs"]
 mod test_last;