Skip to content

[process][freebsd]: Terminal() fails instead of returning "" when no pty is open #2133

Description

@neilpang

Splitting this out of the CI PR as you suggested.

On FreeBSD, devfs materialises /dev/pts only while a pty is open. When none
is, getTerminalMap returns an error, and TerminalWithContext turns that into
a failure for every process -- including ones attached to a real console
terminal, because the /dev/tty* entries it had already collected are thrown
away on the way out.

process/process_posix.go:41-55

for _, devname := range devnames {
    if strings.HasPrefix(devname, "tty") {
        termfiles = append(termfiles, filepath.Join(devPath, devname))
    }
}

var ptsnames []string
ptsPath := filepath.Join(devPath, "pts")
ptsd, err := os.Open(ptsPath)
if err != nil {
    ptsnames, _ = filepath.Glob(filepath.Join(devPath, "ttyp*"))
    if ptsnames == nil {
        return nil, err
    }
    termfiles = append(termfiles, ptsnames...)
} else {

termfiles is already populated at that point. Modern FreeBSD has no
/dev/ttyp*, so the glob is empty and the function returns nil with
everything discarded.

Two jobs on FreeBSD 15.1, identical except that one holds a pty open with
script -q /dev/null sleep 600 &:

no pty open
open /dev/pts -> no such file or directory
/dev/tty* -> 15 entries: ttyu0 ttyu0.init ttyu0.lock
ttyv0 ttyv1 ... ttyvb
/dev/ttyp* -> 0
getTerminalMap -> entries=0 err=open /dev/pts: no such file or directory
Terminal() -> "" err=open /dev/pts: no such file or directory

one pty held open
open /dev/pts -> ok
/dev/tty* -> the same 15 entries
/dev/ttyp* -> 0
getTerminalMap -> entries=16 err=
Terminal() -> "" err=

https://github.com/neilpang/gopsutil/actions/runs/31376838154

The second column shows the answer this process should get: "" with no
error, since it has no controlling terminal. Without a pty it gets the same
"" but with an error attached, so a caller cannot tell "no terminal" from
"lookup failed". A process that does own /dev/ttyv0 would be misreported the
same way, since those 15 entries never reach the map.

This is not specific to CI. On a local FreeBSD 15.1 VM, driven over a
non-interactive ssh session with nothing holding a pty:

ls -d /dev/pts        ->  No such file or directory
ls -d /dev/tty*       ->  ttyu0 ttyu0.init ttyu0.lock ttyv0 ... ttyvb
mount -t devfs devfs /tmp/probedev
ls -d /tmp/probedev/pts   ->  No such file or directory

and once a pty is held, /dev/pts appears with a single entry, 0. So the
trigger is simply that no pty is open on the machine at that moment, which
is the normal state of a headless box being driven non-interactively.

One correction to the jail example from the review: a jail is not a separate
case. Its devfs shows no tty entries at all under the default ruleset, and
/dev/pts appears inside it as soon as the host opens one -- checked with a
real jail (jls confirming, devfs mounted) on that same VM.

Linux does not hit any of this because devpts is always mounted, so the
branch is never taken there.

A minimal fix would be to keep what was collected instead of discarding it:
/dev/pts being absent is a normal FreeBSD state, not a failure to read /dev.
The genuine error case -- /dev itself unreadable -- is already handled
earlier at lines 31-34. Happy to send that patch if you would like it shaped
that way.

Worth noting that process/process_posix_test.go:24-26 already skips when
getTerminalMap errors, while process_test.go's TestTerminal asserts NoError
-- so the suite half expects this already.

Written with the help of Claude; I read and tested all of it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions