#2133·gopsutil

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

Author: neilpangCreated Aug 10, 2026Updated Aug 10, 2026

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.