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.
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
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:
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.