Public bug reported:

## Summary

When a command running under `sudo` with `use_pty` receives SIGTTOU
twice in succession, the parent process fails to send SIGCONT_FG on the
second occurrence. This causes the command to remain permanently stopped
(T state), deadlocking the entire process tree.

The bug is a misplaced `return` in `suspend_pty()` in
`src/exec/use_pty/parent.rs`. The `return Some(SIGCONT_FG)` is nested
inside `if !self.term_raw`, so when `term_raw` is already `true` (set
during the first SIGTTOU handling), the early return is skipped and the
code falls through to the suspend path, which sends SIGTTOU to the
parent process group via `killpg()`.

Original sudo (sudo.ws) does not have this issue — its equivalent `ret =
SIGCONT_FG; break;` is at the `if (ec->foreground)` level, always
executing when the parent is the foreground process.

## Environment

- sudo-rs 0.2.13-0ubuntu1
- `Defaults use_pty` in `/etc/sudoers`
- Linux (tested on 6.x kernel, riscv64)

## Steps to Reproduce

Prerequisites:
- `Defaults use_pty` enabled in `/etc/sudoers`
- A command that triggers SIGTTOU twice (e.g., a dpkg post-install script 
failure during `apt-get full-upgrade`)

Minimal reproduction using Python:

```python
import subprocess, threading

proc = subprocess.Popen(
    ["sudo", "/bin/sh", "-c", "LC_ALL=C apt-get -y full-upgrade"],
    stdin=None,              # inherit terminal
    stdout=subprocess.PIPE,  # pipe stdout
    stderr=subprocess.STDOUT,
    text=True,
)

def drain():
    for line in proc.stdout:
        print(line, end="")

threading.Thread(target=drain, daemon=True).start()
proc.wait(timeout=120)  # hangs here
```

When a dpkg trigger/script fails, `sh` receives SIGTTOU from the kernel
and stops. The monitor sends SIGCONT (via SIGCONT_FG from parent). `sh`
resumes but receives SIGTTOU again. This time, no SIGCONT is sent — the
command stays stopped permanently.

Workaround: use `stdin=subprocess.DEVNULL` to prevent sudo from
detecting a terminal on stdin.

Switching to original sudo (sudo.ws) via `update-alternatives`
eliminates the issue entirely.

## Root Cause

In `src/exec/use_pty/parent.rs`, the `suspend_pty()` function handles
SIGTTOU/SIGTTIN at lines 576-603:

```rust
if let SIGTTOU | SIGTTIN = signal {
    if !self.foreground && self.check_foreground().is_err() {
        return None;
    }

    if self.foreground {
        dev_info!(...);
        if !self.term_raw {
            if self.tty_pipe
                .left_mut()
                .set_raw_mode(false, self.preserve_oflag)
                .is_ok()
            {
                self.term_raw = true;
            }
            // Resume command in the foreground
            self.tty_pipe.enable_input(registry);
            return Some(SIGCONT_FG);  // BUG: only reached when term_raw was 
false
        }
        // When term_raw is already true, falls through!
    }
}

// Falls through to suspend path:
// ...
// killpg(self.parent_pgrp, signal)  ← deadlocks the process tree
```

The equivalent code in original sudo (`src/exec_pty.c:236-246`) does not
have this problem:

```c
if (ec->foreground) {
    if (!ec->term_raw) {
        if (sudo_term_raw(io_fds[SFD_USERTTY], term_raw_flags))
            ec->term_raw = true;
    }
    ret = SIGCONT_FG;   /* always reached when foreground is true */
    break;
}
```

The `ret = SIGCONT_FG; break;` is at the `if (ec->foreground)` scope, so
it executes regardless of `term_raw` state.

## Sequence of Events

```
stdout=PIPE → foreground=false, term_raw=false at startup

1st SIGTTOU:
  command stops (SIGTTOU from kernel)
  → monitor: on_stop() → sends Stop(SIGTTOU) to parent
  → parent: suspend_pty(SIGTTOU)
    → check_foreground() → foreground=true (sudo IS fg process on user tty)
    → term_raw=false → enters if(!term_raw) block
    → set_raw_mode() → term_raw=true
    → returns SIGCONT_FG ✓
  → monitor: tcsetpgrp(command_pgrp) + kill(SIGCONT)
  → command resumes

2nd SIGTTOU:
  command stops again (SIGTTOU from kernel)
  → monitor: on_stop() → sends Stop(SIGTTOU) to parent
  → parent: suspend_pty(SIGTTOU)
    → foreground=true, term_raw=true
    → if(!term_raw) is FALSE → skips the block with return SIGCONT_FG
    → falls through to suspend path
    → killpg(parent_pgrp, SIGTTOU) → stops entire parent process group
    → DEADLOCK: nobody sends SIGCONT to resume ✗
```

## Suggested Fix

Move `return Some(SIGCONT_FG)` and `enable_input()` outside the `if
!self.term_raw` block to match sudo.ws behavior:

```rust
if self.foreground {
    dev_info!(...);
    if !self.term_raw {
        if self.tty_pipe
            .left_mut()
            .set_raw_mode(false, self.preserve_oflag)
            .is_ok()
        {
            self.term_raw = true;
        }
    }
    // Resume command in the foreground (always, regardless of term_raw)
    self.tty_pipe.enable_input(registry);
    return Some(SIGCONT_FG);
}
```

** Affects: rust-sudo-rs (Ubuntu)
     Importance: Undecided
         Status: New

-- 
You received this bug notification because you are a member of Ubuntu
Bugs, which is subscribed to Ubuntu.
https://bugs.launchpad.net/bugs/2146860

Title:
  command permanently stop

To manage notifications about this bug go to:
https://bugs.launchpad.net/ubuntu/+source/rust-sudo-rs/+bug/2146860/+subscriptions


-- 
ubuntu-bugs mailing list
[email protected]
https://lists.ubuntu.com/mailman/listinfo/ubuntu-bugs

Reply via email to