Finding the terminal your script is running in - Programming On Unix

Users browsing this thread: 1 Guest(s)
movq
Long time nixers
This is question that came up in a german board: https://forum.ubuntuusers.de/topic/pruef...mein-srip/

I suspect the original author will be satisfied with a pragmatic solution. I'm forwarding the question to this forum, though, because I hope we can come up with something nice and clever. :) Just for fun.

The goal is to find the PID of the terminal your script is running in. Turns out, this is not as easy as it sounds. The naïve approach is to traverse the process tree. The parent process of your script is ... yeah, what is it? Most likely an interactive shell. But it could also be the terminal you're looking for if you started your script using something like "xterm -e foo.sh". On the other hand, if you launched the script from inside of Vim, there's an additional intermediate process.

Then there's SSH.

Then there's screen and tmux.

Then there's screen over SSH.

Then there's the Linux VT where the terminal emulator is not an actual process.

And so on. You get the idea.

That's what I got so far:

Code:
#!/bin/bash

find_parent_terminal()
{
    pid=$1
    comm=$(ps --no-headers -p $pid -o comm)

    echo Checking process $pid, "'$comm'"

    case $comm in
        sshd)
            echo Looks like I\'m running via SSH, PID $pid, "'$comm'"
            return
            ;;
        login)
            echo Looks like I\'m running on a Linux VT, PID $pid, "'$comm'"
            return
            ;;
    esac

    for fd in /proc/$pid/fd/*
    do
        if [[ "$(readlink -e -- "$fd" 2>/dev/null)" == /dev/ptmx ]]
        then
            echo Determined PID $pid as my terminal, "'$comm'"
            return
        fi
    done

    ppid=$(ps --no-headers -p $pid -o ppid)
    if (( ppid == 1 ))
    then
        echo Reached PID 1, could not find a terminal candidate
    else
        find_parent_terminal $ppid
    fi
}

find_parent_terminal $BASHPID

The idea is to traverse the process tree and look for clues. Terminal emulators usually hold an open file descriptor to /dev/ptmx, so that's what I try to find.

I used a Bash script, but any common language is fine.

Ideally, the solution runs on *BSD as well, but I'd be happy to settle for just Linux.
venam
Administrators
This problem is captivating.

The more I think about it the more it drives me close to your approach: Recursively checking if the parent writes to the pseudo terminal master.

Maybe you can swap the readlink part with an `lsof /dev/ptmx | grep " $pid "`, instead of going through every processes opened file descriptors you go through the file descriptors of ptmx and check if it's there.
Or then you could also do `lsof -p $pid | grep '/dev/ptmx'`
Then continue the recursion.

I'm still sceptical of the edge cases, namely the sshd, login, etc..
There ought to be more than that.

sshd holds a file descriptor to /dev/ptmx, it's the endpoint in that case.
Let's say you have something else that is the "endpoint" and isn't a terminal but with our way it'll be listed as a terminal.
We need other features specific to a terminal.
If we remove the console, maybe we could presuppose the terminal is running in a graphical session.
But then again, maybe ssh could be thought of as a terminal.

I tried with a terminal multiplexer, they lead back to the right terminal, so no issues on that part.
movq
Long time nixers
(03-07-2016, 01:56 AM)venam Wrote: lsof

Good point, that would also make it a little more portable.

(03-07-2016, 01:56 AM)venam Wrote: I tried with a terminal multiplexer, they lead back to the right terminal, so no issues on that part.

Actually, that got me thinking. Is that correct? GNU screen does indeed hold an open fd to /dev/ptmx, but my method above can't find it:
Code:
$ ls -al /proc/14731/fd
ls: cannot open directory '/proc/14731/fd': Permission denied
$ sudo !!
sudo ls -al /proc/14731/fd
total 0
dr-x------ 2 root root   0 Jul  3 16:58 .
dr-xr-xr-x 9 root users  0 Jul  3 16:58 ..
lr-x------ 1 root root  64 Jul  3 17:00 0 -> /dev/null
l-wx------ 1 root root  64 Jul  3 17:00 1 -> /dev/null
l-wx------ 1 root root  64 Jul  3 17:00 2 -> /dev/null
lrwx------ 1 root root  64 Jul  3 16:58 3 -> /dev/pts/3
lrwx------ 1 root root  64 Jul  3 17:00 4 -> 'socket:[1072739]'
lrwx------ 1 root root  64 Jul  3 17:00 5 -> /run/utmp
lrwx------ 1 root root  64 Jul  3 17:00 6 -> /dev/ptmx

Maybe the original problem is not defined very well. What is "the" terminal? Is it supposed to be screen or the xterm that runs screen?

Yes, let's indeed ignore the Linux console for now. It only complicates things. :)

From what I understand, terminal emulators call `openpty()` which gets them a pair of connected file descriptors. The "slave" end is something like `/dev/pts/4` and this is what the shell and other programs see. So, what I'm really looking for would be the "master" end. Meaning, if you run a multiplexer, then that's "the" terminal because this process originally called `openpty()`. (So my script gives the wrong answer, due to missing permissions.)

It's simple to find the slave end because that's STDIN of my script. But how to find the process which holds the corresponding (!) master end? Is that even possible? :/ Even worse: In theory, there could be multiple matching processes because that master file descriptor could be inherited or passed on to other processes (even though that's very unlikely). Traversing the process tree may be a pretty good guess, but it's not necessarily correct.

Whew, this turned out to be way more complicated than I thought.
venam
Administrators
(03-07-2016, 12:41 PM)vain Wrote:
Quote:I tried with a terminal multiplexer, they lead back to the right terminal, so no issues on that part.
Actually, that got me thinking. Is that correct? GNU screen does indeed hold an open fd to /dev/ptmx, but my method above can't find it:
Code:
$ ls -al /proc/14731/fd
ls: cannot open directory '/proc/14731/fd': Permission denied
$ sudo !!
sudo ls -al /proc/14731/fd
total 0
dr-x------ 2 root root   0 Jul  3 16:58 .
dr-xr-xr-x 9 root users  0 Jul  3 16:58 ..
lr-x------ 1 root root  64 Jul  3 17:00 0 -> /dev/null
l-wx------ 1 root root  64 Jul  3 17:00 1 -> /dev/null
l-wx------ 1 root root  64 Jul  3 17:00 2 -> /dev/null
lrwx------ 1 root root  64 Jul  3 16:58 3 -> /dev/pts/3
lrwx------ 1 root root  64 Jul  3 17:00 4 -> 'socket:[1072739]'
lrwx------ 1 root root  64 Jul  3 17:00 5 -> /run/utmp
lrwx------ 1 root root  64 Jul  3 17:00 6 -> /dev/ptmx

Oh, I also had to use sudo.
So the permission is an issue too.

(03-07-2016, 12:41 PM)vain Wrote: From what I understand, terminal emulators call `openpty()` which gets them a pair of connected file descriptors. The "slave" end is something like `/dev/pts/4` and this is what the shell and other programs see. So, what I'm really looking for would be the "master" end. Meaning, if you run a multiplexer, then that's "the" terminal because this process originally called `openpty()`. (So my script gives the wrong answer, due to missing permissions.)

It's simple to find the slave end because that's STDIN of my script. But how to find the process which holds the corresponding (!) master end? Is that even possible? :/ Even worse: In theory, there could be multiple matching processes because that master file descriptor could be inherited or passed on to other processes (even though that's very unlikely). Traversing the process tree may be a pretty good guess, but it's not necessarily correct.
[Image: 800px-Termios-script-diagram.svg.png]

It also got me thinking into the definition of what is a terminal.

If you write a terminal that works using two parts, client and server, which one do you consider to be the terminal?
Probably the server but there's no direct interaction with the server.

Quote:The role of the terminal emulator process is:

to interact with the user.
to feed text input to the master pseudo-device for use by the shell (such as bash), which is connected to the slave pseudo-device).
To read text output from the master pseudo-device and show it to the user.
venam
Administrators
This is an excellent thread from 2016 about terminal introspection.
On the topic, later on I tried to do my own research about terminals. It's a whole rabbit hole.
z3bra
Grey Hair Nixers
There's a patch for dwm that require exactly this. It tries to mimic plan9's swallowing feature. When you an application opens a window, the calling terminal is "replaced" by the new app. For that, it must search for a calling terminal whenever a new window is popping.

Just like vain said, it traverses the process tree (using /proc/$PID/stat, where the 4th value is the parent PID), and for each PID that maps internally to a window (you must keep track of the PID per window), check whether the application class match a predefined one ("St", by default). They also improved it to work on OpenBSD (using libkvm).

See the full patch here: https://dwm.suckless.org/patches/swallow...de9b0.diff

This means that you must rely on X11 and set manually what is and is not a terminal.

At some point, I think it's easier to do that try to "guess" what is a terminal, because many processes can open a pseudo TTY for various reasons (ssh-agent for example). Even you shell ! So that means you'd stop at the shell rather than at the actual terminal.

Edit:

After thinking about it more, I suppose you can somehow guess if a PID is a terminal emulator or not. You'd have to make a few assumptions for what defines a terminal emulator:
  • it has the $DISPLAY environment variable set
  • it is controlling a pseudo TTY (has /dev/ptmx open)
  • it can be associated with a graphical window

If all 3 conditions are met, it could be safe to assume you find a terminal emulator.
movq
Long time nixers
By the way, we totally missed the original intention of the person in the original german thread (the german OP only posted this after venam's last reply in 2016 ... classic X/Y problem):

They have an application that runs in a terminal. This application wants to start new terminals. So, naturally, it needs to know which terminal (XTerm, GNOME terminal, ...) that is. Hence the idea of trying to find the terminal that you're running in.

This finally gives us a much better definition of "the terminal". :) It's an X11/Wayland terminal emulator. Complications like tmux can be ignored entirely.

So, yeah, essentially what z3bra said.

But there's an additional constraint: The original OP wanted to set a window title, e.g. `sakura -t 'my fancy window title' -x 'sleep 20'`. Of course, pretty much every terminal emulator has its own set of command line arguments, so you have to know about all of them and thus you have to test for specific processes (`/proc/$pid/exe`) anyway.

Kind of a weird way to do things. If there was a standard variable like $EDITOR, the problem wouldn't exist in the first place.
venam
Administrators
I recently dived a bit more into this.
This SO thread has a lot of good stuff, along with man(7) pty, and man(4) ptmx, pts, and this lwn article.

While on classic BSD systems, making the link between master and slave pair is much easier as they map to ptyXY and ttyXY with the same XY numbers, on Linux it's not as straight forward.

Initially, I couldn't find any tool to display clearly the link between the process linked to the pseudoterminal master end (/dev/ptmx) and the pseudoterminal slave (/dev/pts/<num>).

Yet, after digging more I could find the following methods:
You can type tty(1) in the shell to get the /dev/pts number. Then interrogating /dev/ptmx give us more info about the parent process. lsof(8) has the +E allows to display the pseudoterminal info.
For example, if the tty is /dev/pts/13, you can do:
Code:
lsof +E /dev/ptmx | awk '/pts\/13/ && /dev\/ptmx/'
Which will output:
Code:
urxvtd       783  vnm   11u   CHR    5,2      0t0   99 /dev/ptmx ->/dev/pts/13 794279,zsh,0u 794279,zsh,1u 794279,zsh,2u 794279,zsh,10u 795200,lsof,0u 795200,lsof,2u 795201,awk,2u 795202,xsel,1u 795202,xsel,2u
This indicates the shell is running on urxvtd as a terminal.

Another way to query the info is to perform ptsname(3) on /dev/ptmx, passing it the file descriptors of the linked terminals, to get the corresponding of pseudoterminal slave ends attached to it.

Manually doing this goes like this, first listing the current master ends and the file descriptors attached.
Code:
lsof -F pcf /dev/ptmx
Example output (the fields are prepended with single letter, weird lsof format):
Code:
p783
curxvtd
f9
f10
f11

Yet another listing method for the master end is to use fuser(1):
Code:
$ fuser -v /dev/ptmx

                     USER        PID ACCESS COMMAND
/dev/ptmx:           vnm         754 F.... urxvt
                     vnm         783 F.... urxvtd
                     vnm         986 F.... tmux: server
                     vnm       803385 F.... urxvtd
                     vnm       805201 F.... xsel

After this, there are multiple ways to find the slave pseudoterminal end.
The easiest way, is to rely on a recent patch (2017) in the Linux kernel has added an entry to /proc/[pid]/fdinfo/<FD> called tty-index. Though this field is not documented in man 5 proc.
The value of tty-index is the associated pts number.

Thus we can now do:
Code:
$ < /proc/783/fdinfo/9


pos:    0
flags:    0104002
mnt_id:    24
tty-index:    1
Then see who has a file descriptor opened on /dev/pts/1.
Code:
lsof /dev/pts/1
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
zsh     796001  vnm    0u   CHR  136,1      0t0    4 /dev/pts/1
zsh     796001  vnm    1u   CHR  136,1      0t0    4 /dev/pts/1
zsh     796001  vnm    2u   CHR  136,1      0t0    4 /dev/pts/1
zsh     796001  vnm   10u   CHR  136,1      0t0    4 /dev/pts/1
vim     796445  vnm    0u   CHR  136,1      0t0    4 /dev/pts/1
vim     796445  vnm    1u   CHR  136,1      0t0    4 /dev/pts/1
vim     796445  vnm    2u   CHR  136,1      0t0    4 /dev/pts/1
So two processes, zsh (796001) and vim (796445) have three file descriptors (stdin, stdout, stderr) opened on /dev/pts/1.

Another method, if tty-index isn't present, is to rely on the ptsname(3) to get the name of the slave pseudoterminal. That's a bit clunky as you'd need to rely on gdb to access the file descriptor of the running process, which might need higher privileges.
There's two ways I've found, the simple one is to do:
Code:
#  gdb -batch -p 986 -ex 'p (char *)ptsname(8)' 2>/dev/null | grep 'dev\/pts'
$1 = 0x7ff95c3ab010 <buffer> "/dev/pts/3"
(beware that it might interfere with the running terminal, putting it in debug mode)

I've seen other answers online that directly do the ioctl that ptsname does in the background (source ioctl request TIOCGPTN = 0x80045430, which I can't find in man 2 ioctl_tty).
Something like:
Code:
# gdb -batch -p 986 -ex 'p (int)ioctl(8, 0x80045430, &errno)?-1: (int)errno' 2>/dev/null | grep '\$1'

$1 = 3
Note here that errno is used as the storage for the pts number and not for actual error.

Now that we got both ends, master and slave then we can print the processes on both ends.

For example:

Code:
# Master
$ ps -p 986 --forest
    PID TTY          TIME CMD
    986 ?        00:04:28 tmux: server

# Slave
$ ps -t pts/3 --forest
    PID TTY          TIME CMD
    987 pts/3    00:01:36 irssi

This can be automated in a script, someone has posted one on SO:
Code:
my (%pty, %ctty);
for(</proc/*[0-9]*/{fd/*,stat}>){
    if(my ($pid, $fd) = m{/proc/(\d+)/fd/(\d+)}){
        next unless -c $_;
        my $rdev = (stat)[6]; my $maj = $rdev >> 8 & 0xfff;
        if($rdev == 0x502){ # /dev/ptmx or /dev/pts/ptmx
            $pty{ptsname($pid, $fd, readlink $_)}{m}{$pid}{$fd} = 1;
        }elsif($maj >= 136 && $maj <= 143){ # /dev/pts/N
            $pty{readlink $_}{s}{$pid}{$fd} = 1;
        }
    }else{
        my @s = readfile($_) =~ /(?<=\().*(?=\))|[^\s()]+/gs;
        $ctty{$s[6]}{$s[0]} =       # ctty{tty}{pid} =
        ($s[4] == $s[7] ? '+' : '-').   # pgrp == tpgid
        ($s[0] == $s[5] ? '*' : '');    # pid == sid
    }
}
for(sort {length($a)<=>length($b) or $a cmp $b} keys %pty){
    print "$_\n";
    pproc(4, $pty{$_}{m}); pproc(8, $pty{$_}{s}, $ctty{(stat)[6]});
}

sub readfile { local $/; my $h; open $h, '<', shift and <$h> }
sub cmdline {
    join ' ', map { s/'/'\\''/g, $_ = "'$_'" if m{^$|[^\w./+=-]}; $_ }
    readfile("/proc/$_[0]/cmdline") =~ /([^\0]*)\0/g;
}
sub pproc {
    my ($px, $h, $sinfo) = @_;
    exists $$h{$_} or $$h{$_} = {''} for keys %$sinfo;
    return printf "%*s???\n", $px, "" unless $h;
    for my $pid (sort {$a<=>$b} keys %$h){
        printf "%*s%-5d %s%-3s   %s\n", $px, "", $pid, $$sinfo{$pid},
        join(',', sort {$a<=>$b} keys %{$$h{$pid}}),
        cmdline $pid;
    }
}
sub ptsname {
    my ($pid, $fd, $ptmx) = @_;
    return '???' unless defined(my $ptn = getptn($pid, $fd));
    $ptmx =~ m{(.*)(?:/pts)?/ptmx$} ? "$1/pts/$ptn" : "$ptmx ..?? pts/$ptn"
}
sub getptn {
    my ($pid, $fd) = @_;
    return $1 if
    readfile("/proc/$pid/fdinfo/$fd") =~ /^tty-index:\s*(\d+)$/m;
    return gdb_ioctl($pid, $fd, 0x80045430);    # TIOCGPTN
}
sub gdb_ioctl {
    my ($pid, $fd, $ioctl) = @_;
    my $cmd = qq{p (int)ioctl($fd, $ioctl, &errno) ? -1 : errno};
    qx{exec 3>&1; gdb -batch -p $pid -ex '$cmd' 2>&1 >&3 |
    grep -v '/sysdeps/.*No such file or directory' >&2}
    =~ /^\$1 *= *(\d+)$/m ? $1 : undef;
}
However, it's not very readable, so I wrote a much simpler script.

Code:
use strict;
use warnings;

print "MASTER\tSLAVES\n";
my $pid = -1;
for my $master_end (qx#lsof -F pcf /dev/ptmx#) {
    chomp $master_end;
    if ($master_end =~ /p.*/) {
        print "\n" if $pid>0;
        $pid = substr($master_end, 1);
        print "$pid,";
    } elsif ($master_end =~ /c.*/) {
        print substr($master_end, 1)."\t";
    }
    elsif ($master_end =~ /f.*/) {
        # a file descriptor for current pid
        my $fd = substr($master_end, 1);
        readfile("/proc/$pid/fdinfo/$fd") =~ /^tty-index:\s*(\d+)$/m;
        my $slave_processes = qx#ps -o "%p,%c" -t pts/$1#;
        $slave_processes =~ s/^(?:.*\n)//;
        $slave_processes =~ s/^\s*//;
        $slave_processes =~ s/\n/\t/g;
        print "$slave_processes";
    }
}
sub readfile { local $/; my $h; open $h, '<', shift and <$h> }
print "\n";

Or you can simply rely on lsof:
Code:
lsof +E /dev/ptmx | awk '/dev\/ptmx/'

Wow, it turned out to be a much deeper dive than I thought! I hope that clarifies things for at least someone. As for the initial question, you can then simply execute one of these and grep for the PID of the process to find the terminal it is running on.
Yet, it still doesn't answer "what is actually considered a terminal".