3

How can I tell if the terminal running my python script was closed? I want to safely end my python script if the user closes the terminal. I can catch SIGHUP with a handler, but not when the script is run as sudo. When I start the script with sudo and close the terminal, the python script keeps running.

Example script:

import signal
import time
import sys

def handler(signum, frame):
    fd = open ("tmp.txt", "a")
    fd.write(str(signum) + " handled\n")
    fd.close()
    sys.exit(0)


signal.signal(signal.SIGHUP, handler)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)

time.sleep(50)

Sometimes the script will execute the handler when run as sudo, but more often it doesn't. The script always writes to the file when ran without sudo. I am running it on a Raspberry Pi. I see the same thing in a LXTerminal and a gnome-terminal. This example script will end after 50 seconds, but my lengthy code runs in an infinite loop

The ultimate goal is to have a .desktop launcher on a Raspberry Pi to do bluetooth scanning and find devices. The bluetooth scanning requires sudo because it use 4.0 BLE. I'm not sure why bluez requires sudo but it does. When type sudo on the pi, it never asks for a password which is fine with me. The problem is that after closing the terminal, the scan process is still running. The scanning is done by a python script that runs in a terminal.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
peteey
  • 420
  • 4
  • 14
  • If the shell runs with user privileges and the script with root privileges, the shell might simply lack the permissions to send a signal the script. Even if the shell tries to send SIGHUP, the script won't receive it in this case. If the shell is running with root privileges as well, it can send SIGHUP – these might have been the case when it worked. – Sven Marnach Sep 10 '15 at 20:48
  • Does it behave like that when you start the terminal as root or with sudo and run your script? – Ozan Sep 10 '15 at 20:52
  • The handler is called when I start the terminal as root! I also noticed that I needed sudo to send signals with the "kill" command when the script was run as sudo. the script is launched from a desktop icon. Do you know how to for the desktop launcher to start the terminal as root? – peteey Sep 10 '15 at 22:55
  • This sounds an awful lot like an [XY problem](http://meta.stackexchange.com/questions/66377/what-is-the-xy-problem). What are you really trying to accomplish? Running a script as `sudo` pretty much requires that it cannot depend on user interaction after that point. A common workaround is to have the privileged program set up a communications channel using named pipes (or some other RPC mechanism) so that an authorized user can continue to communicate with it from their regular account. This may be overkill for what your program does, though; but without more details, we cannot really help – tripleee Sep 11 '15 at 04:41
  • The ultimate goal is to have a .desktop launcher on a Raspberry Pi to do bluetooth scanning and find devices. The bluetooth scanning requires sudo because its 4.0 BLE. I'm not sure why bluez requires sudo but it does. When type sudo on the pi, it never asks for a password which is fine with me. The problem I have been having is that after closing the terminal, the scan process is still running. – peteey Sep 11 '15 at 16:28
  • I cleaned up my post in hopes of being more clear. Please give it another glance – peteey Sep 11 '15 at 16:36
  • @SvenMarnach: It's the kernel that sends SIGHUP when a tty closes, but the semantics are tricky. tripleee: It's running in a terminal where the user can interact with it. I guess the version you were commenting on was less clear. – Peter Cordes Sep 12 '15 at 05:09
  • @petercordes: according to the bash man page, it's the shell that sends SIGHUP: "Before exiting, an interactive shell resends the SIGHUP to all jobs, running or stopped.", followed by explanations how you can prevent the shell from sending the signal (disown and the huponexit option). – Sven Marnach Sep 12 '15 at 23:29
  • @SvenMarnach: Yup, you can see the kill(-pid, SIGHUP) in the strace output. It fails with EPERM because there are no processes in that process-group that bash has permission to send signals to. None of the signals received by the uid=root `sudo` or `sig-counter` processes were from uid=1000 bash, just from the kernel. (or relayed to sig-counter by sudo). The important point is that bash exits on SIGHUP, and it was the owner or whatever it is of the slave end of the pty, so it disappears when bash exits, resulting in the other processes getting a SIGHUP from the kernel. – Peter Cordes Sep 12 '15 at 23:35
  • @PeterCordes: Thanks for the details – so it may be either the shell or the kernel sending SIGHUP. The details are indeed more involved than I thought. – Sven Marnach Sep 13 '15 at 17:32
  • 1
    @SvenMarnach: The shell tries to send SIGHUP, but it's not running as root, so there's no chance of it being able to signal sudo. The only way it works is for the kernel to send SIGHUP to sudo's child (and sudo), since sudo is designed not to relay SIGHUPs sent by the kernel. And yes, it's fairly involved. If you look at the edit history of my answer, you'll see it took me about 3 stages of figuring out what was going on, with different conclusions. – Peter Cordes Sep 13 '15 at 21:07

1 Answers1

7

sudo is designed for the SIGHUP semantics you get when it's a child of some other process on the tty. In that case, all processes get their own SIGHUP from the kernel when the parent exits.

xterm -e sudo cmd runs sudo directly on the pseudo-terminal. This produces different SIGHUP semantics than sudo is expecting. Only sudo receives a SIGHUP from the kernel, and doesn't relay it because it's expecting that it gets a SIGHUP from the kernel only when its child process also got its own (because of something sudo's parent (e.g. bash) does).

I reported the issue upstream, and it's now marked as fixed in sudo 1.8.15 and onwards.

Workaround:

xterm -e 'sudo ./sig-counter; true'

# or for uses that don't implicitly use a shell:
xterm -e sh -c 'sudo some-cmd; true'

If your -c argument is a single command, bash optimizes by execing it. Tacking another command (the trivial true in this case), gets bash to stick around and run sudo as a child. I tested, and with this method, sig-counter gets one SIGHUP from the kernel when you close xterm. (It should be the same for any other terminal emulator.)

I've tested this, and it works with bash and dash. Source included for a handy-dandy signal-receiving-without-exiting program which you can strace to see all the signals it receives.


Some parts of the rest of this answer may be slightly out of sync. I went through a few theories and testing methods before figuring out the sudo as controlling process vs. sudo as child of a shell difference.


POSIX says that close() on the master end of a pseudo-terminal causes this: "a SIGHUP signal shall be sent to the controlling process, if any, for which the slave side of the pseudo-terminal is the controlling terminal."

The POSIX wording for close() implies there can be only one processing process that has the pty as its controlling terminal.

When bash is the controlling process for the slave side of a pty, it does something that causes all other processes to receive a SIGHUP. This is the semantics sudo is expecting.

ssh localhost, then abort the connection with ~. or kill your ssh client.

$ ssh localhost
ssh$ sudo ~/.../sig-counter  # without exec
   # on session close: gets a SIGHUP and a SIGCONT from the kernel

$ ssh localhost
ssh$ exec sudo ~/src/experiments-sys/sig-counter
   # on session close: gets only a SIGCONT SI_USER relayed from sudo

$ ssh -t localhost sudo ~/src/experiments-sys/sig-counter
   # on session close: gets only a SIGCONT SI_USER relayed from sudo

$ xterm -e sudo ./sig-counter
           # on close: gets only a SIGCONT SI_USER relayed from sudo

Testing this was tricky, because xterm also sends a SIGHUP on its own, before exiting and closing the pty. Other terminal emulators (gnome-terminal, konsole) may or may not do this. I had to write a signal-testing program myself to not just die after the first SIGHUP.

Unless xterm is running as root, it can't send signals to sudo, so sudo only gets the signals from the kernel. (Because it is the controlling process for the tty, and the process running under sudo isn't.)

The sudo man page says:

Unless the command is being run in a new pty, the SIGHUP, SIGINT and SIGQUIT signals are not relayed unless they are sent by a user process, not the kernel. Otherwise, the command would receive SIGINT twice every time the user entered control-C.

It looks to me like sudo's double-signal avoidance logic for SIGHUP was designed for running as a child of an interactive shell. When there's no interactive shell involved (after exec sudo from an interactive shell, or when there was no shell involved in the first place), only the parent process (sudo) gets a SIGHUP.

sudo's behaviour is good for SIGINT and SIGQUIT, even in an xterm with no shell involved: after pressing ^C or ^\ in the xterm, sig-counter receives exactly one SIGINT or SIGQUIT. sudo receives one and doesn't relay it. si_code=SI_KERNEL in both processes.


Tested on Ubuntu 15.04, sudo --version: 1.8.9p5. xterm -v: XTerm(312).

###### No sudo
$ pkill sig-counter; xterm -e ./sig-counter &

$ strace -p $(pidof sig-counter)
Process 19446 attached
   quit xterm (ctrl-left click -> quit)
rt_sigtimedwait(~[TERM RTMIN RT_1], {si_signo=SIGHUP, si_code=SI_USER, si_pid=19444, si_uid=1000}, NULL, 8) = 1  # from xterm
rt_sigtimedwait(~[TERM RTMIN RT_1], {si_signo=SIGHUP, si_code=SI_KERNEL}, NULL, 8) = 1    # from the kernel
rt_sigtimedwait(~[TERM RTMIN RT_1], {si_signo=SIGCONT, si_code=SI_KERNEL}, NULL, 8) = 18   # from the kernel
   sig-counter is still running, because it only exits on SIGTERM

 #### with sudo, attaching to sudo and sig-counter after the fact
 # Then send SIGUSR1 to sudo
 # Then quit xterm

 $ sudo pkill sig-counter; xterm -e sudo ./sig-counter &
 $ sudo strace -p 20398  # sudo's pid
restart_syscall(<... resuming interrupted call ...>) = ? 
ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGUSR1 {si_signo=SIGUSR1, si_code=SI_USER, si_pid=20540, si_uid=0} ---
write(7, "\n", 1)                       = 1   # FD 7 is the write end of a pipe. sudo's FD 6 is the other end.  Some kind of deadlock-avoidance?
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
poll([{fd=6, events=POLLIN}], 1, 4294967295) = 1 ([{fd=6, revents=POLLIN}])
read(6, "\n", 1)                        = 1
kill(20399, SIGUSR1)                    = 0   ##### Passes it on to child
read(6, 0x7fff67d916ab, 1)              = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=6, events=POLLIN}], 1, 4294967295

     ####### close xterm
--- SIGHUP {si_signo=SIGHUP, si_code=SI_KERNEL} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
--- SIGCONT {si_signo=SIGCONT, si_code=SI_KERNEL} ---   ### sudo gets both SIGHUP and SIGCONT
write(7, "\22", 1)                      = 1
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
poll([{fd=6, events=POLLIN}], 1, 4294967295) = 1 ([{fd=6, revents=POLLIN}])
read(6, "\22", 1)                       = 1
kill(20399, SIGCONT)                    = 0   ## but only passes on SIGCONT
read(6, 0x7fff67d916ab, 1)              = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=6, events=POLLIN}], 1, 4294967295
## keeps running after xterm closes

 $ sudo strace -p $(pidof sig-counter)  # in another window
rt_sigtimedwait(~[RTMIN RT_1], {si_signo=SIGUSR1, si_code=SI_USER, si_pid=20398, si_uid=0}, NULL, 8) = 10
rt_sigtimedwait(~[RTMIN RT_1], {si_signo=SIGCONT, si_code=SI_USER, si_pid=20398, si_uid=0}, NULL, 8) = 18
## keeps running after xterm closes

The command running under sudo only sees a SIGCONT when the xterm closes.

Note that clicking the window-manager's close button on xterm's titlebar just makes xterm send a SIGHUP manually. Often this will cause the process inside xterm to close, in which case xterm exits after that. Again, this is just xterm's behaviour.


This is what bash does when it gets SIGHUP, producing the behaviour sudo expects:

Process 26121 attached
wait4(-1, 0x7ffc9b8c78c0, WSTOPPED|WCONTINUED, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGHUP {si_signo=SIGHUP, si_code=SI_KERNEL} ---
--- SIGCONT {si_signo=SIGCONT, si_code=SI_KERNEL} ---
   ... write .bash history ...
kill(4294941137, SIGHUP)                = -1 EPERM (Operation not permitted)  # This is kill(-26159), which signals all processes in that process group
rt_sigprocmask(SIG_BLOCK, [CHLD TSTP TTIN TTOU], [CHLD], 8) = 0
ioctl(255, SNDRV_TIMER_IOCTL_SELECT or TIOCSPGRP, [26121]) = -1 ENOTTY (Inappropriate ioctl for device) # tcsetpgrp()
rt_sigprocmask(SIG_SETMASK, [CHLD], NULL, 8) = 0
setpgid(0, 26121)                       = -1 EPERM (Operation not permitted)
rt_sigaction(SIGHUP, {SIG_DFL, [], SA_RESTORER, 0x7f3b25ebf2f0}, {0x45dec0, [HUP INT ILL TRAP ABRT BUS FPE USR1 SEGV USR2 PIPE ALRM TERM XCPU XFSZ VTALRM SYS], SA_RESTORER, 0x7f3b25ebf2f0}, 8) = 0
kill(26121, SIGHUP)                     = 0 ## exit in a way that lets bash's parent see that SIGHUP killed it.
--- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=26121, si_uid=1000} ---
+++ killed by SIGHUP +++

I'm not sure which part of this gets the job done. Probably the actual exiting is the trick, or something it did before launching the command, since kill and tcsetpgrp() both failed.


My first attempt at trying it myself was:

xterm -e sudo strace -o /dev/pts/11 sleep 60

(where pts/11 is another terminal.) sleep exits after the first SIGHUP, so testing without sudo just shows the SIGHUP sent manually by xterm.

sig-counter.c:

// sig-counter.c.
// http://stackoverflow.com/questions/32511170/terminate-sudo-python-script-when-the-terminal-closes
// gcc -Wall -Os -std=gnu11 sig-counter.c -o sig-counter
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>

#define min(x, y) ({                \
    typeof(x) _min1 = (x);          \
    typeof(y) _min2 = (y);          \
    (void) (&_min1 == &_min2);      \
    _min1 < _min2 ? _min1 : _min2; })

int sigcounts[64];
static const int sigcount_size = sizeof(sigcounts)/sizeof(sigcounts[0]);

void handler(int sig_num)
{
    sig_num = min(sig_num, sigcount_size);
    sigcounts[sig_num]++;
}

int main(void)
{
    sigset_t sigset;
    sigfillset(&sigset);
    // sigdelset(&sigset, SIGTERM);

    if (sigprocmask(SIG_BLOCK, &sigset, NULL))
        perror("sigprocmask: ");

    const struct timespec timeout = { .tv_sec = 60 };
    int sig;
    do {
        // synchronously receive signals, instead of installing a handler
        siginfo_t siginfo;
        int ret = sigtimedwait(&sigset, &siginfo, &timeout);
        if (-1 == ret) {
            if (errno == EAGAIN) break; // exit after 60 secs with no signals
            else continue;
        }
        sig = siginfo.si_signo;
//      switch(siginfo.si_code) {
//      case SI_USER:  // printf some stuff about the signal... just use strace

        handler(sig);
    } while (sig != SIGTERM );

    //sigaction(handler, ...);
    //sleep(60);
    for (int i=0; i<sigcount_size ; i++) {
        if (sigcounts[i]) {
            printf("counts[%d] = %d\n", i, sigcounts[i]);
        }
    }
}

My first attempt at this was perl, but installing a signal handler wasn't stopping perl from exitting on SIGHUP after the signal handler returned. I saw the message appear right before xterm closed.

cmd=perl\ -e\ \''use strict; use warnings; use sigtrap qw/handler signal_handler normal-signals/; sleep(60); sub signal_handler { print "Caught a signal $!"; }'\';
xterm -e "$cmd" &

Apparently perl signal handling is fairly complicated because perl has to defer them until it's not in the middle of something that doesn't do proper locking.

Unix syscalls in C is the "default" way to do systems programming, so that takes out any possible confusion. strace is often a cheap way to avoid actually writing logging / printing code for playing around with stuff. :P

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
  • I don't have xterm, but if you start the terminal itself as sudo, the signal should be sent. "sudo xterm -e sudo strace -o /dev/pts/11 sleep 60" I dont know how to incorporate this into a .desktop launcher. That's the last piece of the puzzle... – peteey Sep 11 '15 at 17:08
  • @petEEy: It's the kernel that sends the signal when the controlling tty closes, not the process controlling the other side of the pseudo-terminal. From `sudo sleep`'s perspective, it's the same if it's running in an xterm that closes of if it's running on a serial port connected to a modem that hangs up. However, `sudo xterm` does result in a signal being sent. I'm guessing it's something to do with the ownership of the tty device. POSIX almost certainly specifies why this happens. – Peter Cordes Sep 11 '15 at 17:27
  • @petEEy: Hrm, actually I may be mistaken here. The strace output indicates the signal source is `SI_USER`, and the pid is the xterm's pid. – Peter Cordes Sep 11 '15 at 17:34
  • @petEEy: ok, I got to the bottom of this. `sudo` is not passing along SIGHUP because it thinks its child will get one of its own from the kernel, but that doesn't happen. POSIX says it shouldn't, so sudo is broken. – Peter Cordes Sep 12 '15 at 02:54
  • @petEEy: found a workaround. `sudo` isn't exactly broken, but it assumes it's the child of something else. I'm not sure how it should check if it's the process group leader or whatever it's called. – Peter Cordes Sep 12 '15 at 05:06
  • @petEEy: well obviously you have to find the right syntax for passing a command for them to run. They don't all use `-e args` and take all remaining args as a command the way xterm does. – Peter Cordes Sep 14 '15 at 16:24