Skip to content

Commit 60460ca

Browse files
committed
Inspect process tree on macOS and the BSDs
1 parent da5a5d4 commit 60460ca

3 files changed

Lines changed: 254 additions & 31 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
## [`12.0.3` (2026-04-28)](https://github.com/kdeldycke/extra-platforms/compare/v12.0.2...v12.0.3)
99

1010
- Extend missing-shell tolerance to `test_skip_all_shells`, `test_skip_bash`, and `test_skip_powershell` in `tests/test_pytest.py`. The `12.0.2` shell-tolerance work only covered `tests/test_root.py`, leaving these three tests failing on sandboxed builders (Guix, BusyBox-only images) where no shell is detected.
11+
- Detect the active shell on macOS and the BSDs by walking the parent process tree via `ps` when `/proc` is unavailable. The parent-process fallback was previously Linux-only, so it silently did nothing on `/proc`-less systems. The `ps` call degrades to no detection (not an error) when blocked, missing, or when `subprocess.run` is globally mocked.
12+
- Recognize login shells (`argv[0]` like `-bash`) and survive an unreadable `/proc/<pid>/exe` by also reading `/proc/<pid>/cmdline` during the `/proc` walk.
13+
- Read the parent PID from `/proc/<pid>/status` on BSD procfs (FreeBSD, DragonFly), in addition to the Linux `/proc/<pid>/stat` layout.
1114

1215
## [`12.0.2` (2026-04-28)](https://github.com/kdeldycke/extra-platforms/compare/v12.0.1...v12.0.2)
1316

extra_platforms/detection.py

Lines changed: 162 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575

7676
import os
7777
import platform
78+
import subprocess
7879
import sys
7980
from functools import cache
8081
from os import environ
@@ -907,40 +908,168 @@ def _active_env_var_shell_ids() -> frozenset[str]:
907908
)
908909

909910

910-
@cache
911-
def _parent_process_exe_names() -> frozenset[str]:
912-
"""Collect executable names from the parent process tree.
911+
def _shell_name(command: str) -> str:
912+
"""Normalize a process command into a comparable shell name.
913+
914+
Strips a leading ``-`` (which marks a login shell, like ``-bash``), then
915+
returns the lowercased filename stem (``/usr/bin/bash`` becomes ``bash``).
916+
Returns an empty string when no name can be extracted.
917+
"""
918+
return PurePosixPath(command.lstrip("-")).stem.lower()
913919

914-
On Linux, reads ``/proc/<pid>/exe`` symlinks up the process tree via
915-
``/proc/<pid>/stat`` to find parent PIDs. Returns a {class}`frozenset`
916-
of lowercased executable stems (like ``"bash"``, ``"python3"``).
917920

918-
Returns an empty set on non-Linux platforms where ``/proc`` is
919-
unavailable.
921+
def _parse_proc_ppid(record: str) -> int:
922+
"""Extract the parent PID from a ``/proc/<pid>`` process record.
923+
924+
Two on-disk layouts are recognized, told apart by the presence of the
925+
parenthesized ``comm`` field:
926+
927+
- Linux ``stat`` (and NetBSD's Linux-compatible ``stat``):
928+
``"pid (comm) state ppid ..."``. The ``comm`` field may itself contain
929+
spaces and parentheses, so the PPID is taken as the second field after
930+
the last ``)``.
931+
- BSD ``status`` (FreeBSD, DragonFly): ``"comm pid ppid pgid ..."``, where
932+
the PPID is the third whitespace-separated field.
933+
934+
Raises {class}`ValueError` or {class}`IndexError` on an unparsable record.
935+
"""
936+
marker = record.rfind(")")
937+
if marker != -1:
938+
return int(record[marker + 2 :].split()[1])
939+
return int(record.split()[2])
940+
941+
942+
def _ppid_from_proc(pid: int) -> int | None:
943+
"""Read the parent PID of ``pid`` from ``/proc``.
944+
945+
Tries the Linux-style ``stat`` file first, then the BSD-style ``status``
946+
file, so the same walk works across Linux, NetBSD, and FreeBSD. Returns
947+
{data}`None` when neither file can be read or parsed.
948+
"""
949+
for proc_file in ("stat", "status"):
950+
try:
951+
record = Path(f"/proc/{pid}/{proc_file}").read_text()
952+
except OSError:
953+
continue
954+
try:
955+
return _parse_proc_ppid(record)
956+
except (ValueError, IndexError):
957+
return None
958+
return None
959+
960+
961+
def _exe_names_from_proc() -> frozenset[str]:
962+
"""Walk the parent process tree through ``/proc`` (Linux and BSD procfs).
963+
964+
For each ancestor, collects both the resolved executable
965+
(``/proc/<pid>/exe``, so a ``/bin/sh`` symlinked to Bash reports ``bash``)
966+
and its ``argv[0]`` (``/proc/<pid>/cmdline``). Reading both means a login
967+
shell is still recognized when the ``exe`` symlink is unreadable (hardened
968+
``/proc`` mounted with ``hidepid``), and vice versa.
920969
"""
921970
names: set[str] = set()
971+
pid = os.getpid()
972+
visited: set[int] = set()
973+
while pid > 1 and pid not in visited:
974+
visited.add(pid)
975+
# Resolved executable target (follows /bin/sh -> bash symlinks).
976+
try:
977+
if name := _shell_name(os.readlink(f"/proc/{pid}/exe")):
978+
names.add(name)
979+
except OSError:
980+
pass
981+
# argv[0] from the raw, null-separated command line.
982+
try:
983+
cmdline = Path(f"/proc/{pid}/cmdline").read_bytes()
984+
argv0 = cmdline.split(b"\0", 1)[0].decode(errors="replace")
985+
if name := _shell_name(argv0):
986+
names.add(name)
987+
except OSError:
988+
pass
989+
ppid = _ppid_from_proc(pid)
990+
if ppid is None:
991+
break
992+
pid = ppid
993+
return frozenset(names)
994+
995+
996+
def _exe_names_from_ps() -> frozenset[str]:
997+
"""Walk the parent process tree through ``ps``.
998+
999+
Used on POSIX systems without a usable ``/proc`` (notably macOS, and BSDs
1000+
that do not mount procfs). Parses a single ``ps`` snapshot into a
1001+
``{pid: (ppid, command)}`` table, then walks from the current process up to
1002+
the root, taking each ancestor's ``argv[0]`` as its shell name.
1003+
1004+
Only short option flags are passed: the BSD and macOS ``ps`` have no
1005+
long-form equivalents. The approach mirrors
1006+
[shellingham](https://github.com/sarugaku/shellingham) for ``/proc``-less
1007+
systems.
1008+
"""
9221009
try:
923-
pid = os.getpid()
924-
visited: set[int] = set()
925-
while pid > 1 and pid not in visited:
926-
visited.add(pid)
927-
try:
928-
names.add(Path(os.readlink(f"/proc/{pid}/exe")).stem.lower())
929-
except OSError:
930-
pass
931-
try:
932-
stat_content = Path(f"/proc/{pid}/stat").read_text()
933-
# Format: "pid (comm) state ppid ...". The comm field may
934-
# contain spaces and parentheses, so find the last ')'.
935-
ppid_str = stat_content[stat_content.rfind(")") + 2 :].split()[1]
936-
pid = int(ppid_str)
937-
except (OSError, ValueError, IndexError):
938-
break
939-
except OSError:
940-
pass
1010+
result = subprocess.run(
1011+
("ps", "-A", "-ww", "-o", "pid=,ppid=,command="),
1012+
capture_output=True,
1013+
text=True,
1014+
check=True,
1015+
timeout=2,
1016+
)
1017+
except (OSError, subprocess.SubprocessError):
1018+
return frozenset()
1019+
1020+
# str() coerces an unexpected stdout (like a globally mocked subprocess.run
1021+
# returning a Mock) to text, so parsing degrades to an empty result instead
1022+
# of raising.
1023+
output = str(result.stdout)
1024+
1025+
# Parse "<pid> <ppid> <command...>" rows into {pid: (ppid, command)}.
1026+
table: dict[int, tuple[int, str]] = {}
1027+
for line in output.splitlines():
1028+
fields = line.split(maxsplit=2)
1029+
if len(fields) < 2:
1030+
continue
1031+
try:
1032+
child, parent = int(fields[0]), int(fields[1])
1033+
except ValueError:
1034+
continue
1035+
table[child] = (parent, fields[2] if len(fields) == 3 else "")
1036+
1037+
names: set[str] = set()
1038+
pid = os.getpid()
1039+
visited: set[int] = set()
1040+
while pid > 1 and pid in table and pid not in visited:
1041+
visited.add(pid)
1042+
ppid, command = table[pid]
1043+
# argv[0] is the first whitespace-separated token of the command.
1044+
if command and (name := _shell_name(command.split(maxsplit=1)[0])):
1045+
names.add(name)
1046+
pid = ppid
9411047
return frozenset(names)
9421048

9431049

1050+
@cache
1051+
def _parent_process_exe_names() -> frozenset[str]:
1052+
"""Collect executable names from the parent process tree.
1053+
1054+
Returns a {class}`frozenset` of lowercased executable stems (like
1055+
``"bash"`` or ``"python3"``) gathered by walking from the current process
1056+
up to the root. Dispatches on what the platform exposes:
1057+
1058+
- ``/proc`` when present (Linux always, BSDs that mount procfs): no
1059+
subprocess is spawned.
1060+
- ``ps`` otherwise (macOS, BSDs without procfs).
1061+
- An empty set on Windows, which has neither.
1062+
"""
1063+
# /proc, when present, needs no subprocess (Linux always, procfs BSDs).
1064+
if Path("/proc").is_dir():
1065+
return _exe_names_from_proc()
1066+
# macOS and BSDs without procfs: walk the tree via `ps`. Windows, which
1067+
# has neither /proc nor `ps`, falls through to an empty set.
1068+
if os.name == "posix":
1069+
return _exe_names_from_ps()
1070+
return frozenset()
1071+
1072+
9441073
def _parent_process_shells(shell_ids: str | tuple[str, ...]) -> bool:
9451074
"""Check if any parent process in the tree matches the given shell IDs.
9461075
@@ -977,8 +1106,9 @@ def _detect_shell(
9771106
reports the actual shell implementation rather than the interface name:
9781107
when ``/bin/sh`` symlinks to ``/bin/bash``, ``bash`` is detected, not
9791108
``sh``.
980-
3. Falls back to walking the parent process tree via `/proc` to find the
981-
active shell (for stripped environments without shell env vars).
1109+
3. Falls back to walking the parent process tree (via `/proc` on Linux,
1110+
`ps` on macOS and the BSDs) to find the active shell, for stripped
1111+
environments without shell env vars.
9821112
9831113
:param version_env_var: Shell-specific environment variable name
9841114
(like ``"BASH_VERSION"``).
@@ -1733,8 +1863,9 @@ def current_shell(strict: bool = False) -> Shell:
17331863
17341864
1. Shell-specific environment variables (strongest: the Python process
17351865
*is* the shell).
1736-
2. ``/proc`` parent process tree (strong: the shell is an ancestor
1737-
process actively running).
1866+
2. Parent process tree, read from ``/proc`` on Linux or ``ps`` on macOS
1867+
and the BSDs (strong: the shell is an ancestor process actively
1868+
running).
17381869
3. ``SHELL`` environment variable resolved through symlinks (weak:
17391870
configured login shell, may differ from the active shell).
17401871
@@ -1776,7 +1907,7 @@ def current_shell(strict: bool = False) -> Shell:
17761907
from .shell_data import POWERSHELL, SH, UNKNOWN_SHELL
17771908

17781909
# Collect all matching shells via the full scan (env vars, SHELL=,
1779-
# /proc tree, Windows defaults).
1910+
# parent process tree, Windows defaults).
17801911
matching: set[Shell] = {
17811912
shell # type: ignore[misc]
17821913
for shell in ALL_SHELLS

tests/test_detection.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
import ast
1818
import functools
1919
import inspect
20+
import os
2021
import re
22+
import subprocess
23+
import sys
2124
from itertools import chain
2225
from pathlib import Path
2326

@@ -240,3 +243,89 @@ def test_detection_no_circular_dependencies():
240243

241244
# If no exception was raised, there are no circular dependencies.
242245
invalidate_caches()
246+
247+
248+
@pytest.mark.parametrize(
249+
("command", "expected"),
250+
(
251+
("/usr/bin/bash", "bash"),
252+
("/bin/zsh", "zsh"),
253+
("zsh", "zsh"),
254+
("/opt/homebrew/bin/fish", "fish"),
255+
# Login shells carry a leading dash on argv[0].
256+
("-bash", "bash"),
257+
("-zsh", "zsh"),
258+
# The name is lowercased.
259+
("/usr/bin/PYTHON3", "python3"),
260+
# Nothing extractable.
261+
("", ""),
262+
("-", ""),
263+
),
264+
)
265+
def test_shell_name(command, expected):
266+
assert detection_module._shell_name(command) == expected
267+
268+
269+
@pytest.mark.parametrize(
270+
("record", "expected"),
271+
(
272+
# Linux /proc/<pid>/stat: "pid (comm) state ppid ...".
273+
("4242 (bash) S 4200 4242 4200 0 ...", 4200),
274+
# The comm field may itself contain spaces and parentheses.
275+
("4242 (a (weird) name) S 7 1 1 0 ...", 7),
276+
# BSD /proc/<pid>/status: "comm pid ppid pgid ...".
277+
("zsh 4242 4200 4200 ...", 4200),
278+
),
279+
)
280+
def test_parse_proc_ppid(record, expected):
281+
assert detection_module._parse_proc_ppid(record) == expected
282+
283+
284+
def test_exe_names_from_ps(monkeypatch):
285+
"""The ps-based walk (macOS/BSD) climbs from the current process to root."""
286+
# pid ppid command: a login shell (-zsh) under launchd, running pytest.
287+
table = (
288+
" 100 1 /sbin/launchd\n"
289+
" 200 100 -zsh\n"
290+
" 300 200 /usr/bin/python3 -m pytest\n"
291+
)
292+
monkeypatch.setattr(
293+
subprocess,
294+
"run",
295+
lambda *args, **kwargs: subprocess.CompletedProcess([], 0, stdout=table),
296+
)
297+
monkeypatch.setattr(os, "getpid", lambda: 300)
298+
assert detection_module._exe_names_from_ps() == frozenset({
299+
"launchd",
300+
"python3",
301+
"zsh",
302+
})
303+
304+
305+
def test_exe_names_from_ps_tolerates_mocked_run(monkeypatch):
306+
"""A globally mocked subprocess.run must not break the walk."""
307+
308+
class _Sentinel:
309+
stdout = object() # Non-string stdout, like a MagicMock attribute.
310+
311+
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: _Sentinel())
312+
assert detection_module._exe_names_from_ps() == frozenset()
313+
314+
def _boom(*args, **kwargs):
315+
raise FileNotFoundError("ps")
316+
317+
monkeypatch.setattr(subprocess, "run", _boom)
318+
assert detection_module._exe_names_from_ps() == frozenset()
319+
320+
321+
def test_parent_process_exe_names():
322+
"""The dispatcher returns a clean frozenset on every platform."""
323+
invalidate_caches()
324+
names = detection_module._parent_process_exe_names()
325+
assert isinstance(names, frozenset)
326+
# Whatever is discovered must be non-empty, lowercased stems. The set
327+
# itself may be empty on sandboxed builders with neither /proc nor ps.
328+
assert all(name and name == name.lower() for name in names)
329+
if sys.platform == "win32":
330+
assert names == frozenset()
331+
invalidate_caches()

0 commit comments

Comments
 (0)