Troubleshooting¶
Attaching to a live process fails for a handful of environment-specific
reasons that all surface as the same opaque PermissionError or
RuntimeError. pidprobe doctor exists to tell them apart, so start there:
It attaches to nothing, which is why it is safe to point at a production process. Every check it reports has a section on this page, under the same name and in the same order, so a failing check reads straight across:
...is ptrace_scope below.
Reading a doctor report¶
Each check comes back as one of four statuses:
| Status | Meaning |
|---|---|
OK |
The condition attaching needs is satisfied |
WARN |
Attaching still works, but something is degraded or unverified |
FAIL |
Attaching cannot work until this is fixed |
SKIPPED |
The check does not apply here, typically on another platform |
A WARN or FAIL always carries three things: the cause, a confirm
command you can run yourself, and the fix. That is enforced by the type
behind the report, so no check can ever come back as a bare "Permission
denied".
doctor exits 0 when nothing blocks attaching and
7 when at least one check failed; warnings do not
change the exit code. Without a PID only the first five checks run -- the
ones that describe this environment rather than a target.
The JSON report¶
--json prints the same report for tooling:
{
"pid": 12345,
"attachable": false,
"checks": [
{ "name": "prober_remote_debug", "status": "ok", "summary": "..." },
{
"name": "ptrace_scope",
"status": "fail",
"summary": "kernel.yama.ptrace_scope is 2",
"cause": "...",
"confirm": "cat /proc/sys/kernel/yama/ptrace_scope",
"fix": "..."
}
]
}
attachable is false exactly when some check is fail. cause, confirm
and fix are omitted from a check that has nothing to explain, so a passing
check stays a short object. --pretty indents it.
prober_remote_debug¶
Can the interpreter running pidprobe inject at all?
pidprobe injects through PEP 768, so its own interpreter needs
sys.remote_exec. This check fails in two ways: the function is missing
entirely -- a build older than CPython 3.14, or one configured
--without-remote-debug -- or it is present but switched off, because
PYTHON_DISABLE_REMOTE_DEBUG is set in pidprobe's environment. CPython then
refuses with "Remote debugging is not enabled" before it ever looks at the
target.
python3 -c "import sys; print(sys.is_remote_debug_enabled())"
env | grep PYTHON_DISABLE_REMOTE_DEBUG
Install pidprobe under a CPython 3.14+ build with remote debugging compiled
in, such as a python.org or uv-managed one, and unset
PYTHON_DISABLE_REMOTE_DEBUG before running it.
Note
This is about the prober. The same variable in the target's environment
is a different check, target_remote_debug, with
a different remedy: the target has to be restarted.
return_channel¶
Can the target be given somewhere to answer?
The injected script writes its result to a channel the prober creates first.
An AF_UNIX socket is preferred; when one cannot be bound, pidprobe falls
back to a temporary file it polls. The check warns on that fallback and fails
only when neither can be created, which means the probe would have nowhere to
send its answer.
A sandbox, a filesystem without socket support, and a TMPDIR too long for
the 104-byte sun_path limit all look the same from here. Point TMPDIR at a
short directory both pidprobe and the target may write to and read from. The
warning is not fatal -- probing works over the file fallback, only by polling.
collector_plugins¶
Did every installed collector plugin load?
Discovery never fails a snapshot: a plugin that cannot be imported, hands back
something that is not a collector, carries source that does not compile, or
claims a section name a built-in or an earlier plugin already took is logged
and left out. That is right for a snapshot and useless for a diagnosis, so
doctor listens to the pidprobe.registry logger and warns when anything was
skipped. OK lists the collectors that will run.
python3 -c "import logging; logging.basicConfig(); from pidprobe import available_collectors; available_collectors()"
Reinstall or uninstall the package publishing the failing
pidprobe.collectors entry point. A skipped plugin costs only its own
snapshot section, so every other section still comes back. See Writing a
collector plugin for what a collector has to look like.
ptrace_scope¶
Does the Linux Yama policy permit attaching? SKIPPED off Linux.
PEP 768 writes into the target with process_vm_writev, which needs
PTRACE_MODE_ATTACH, and the Yama LSM decides who is granted it:
kernel.yama.ptrace_scope |
Status | Who may attach |
|---|---|---|
| knob absent | OK |
This kernel does not restrict attaching |
0 |
OK |
Anyone, within the same user |
1 |
WARN |
Only a direct ancestor of the target, or a process holding CAP_SYS_PTRACE |
2 |
FAIL |
Only a process holding CAP_SYS_PTRACE |
3 |
FAIL |
Nobody, and the value cannot be lowered while the machine runs |
At scope 1 or 2, run pidprobe as root, give its interpreter the capability
with sudo setcap cap_sys_ptrace+ep $(readlink -f $(command -v python3)), or
relax the knob with sudo sysctl -w kernel.yama.ptrace_scope=0. At scope 3
nothing can attach until the machine is rebooted with a lower value set in
/etc/sysctl.d/.
Warning
setcap on a shared interpreter grants that capability to everything
run through it. Prefer a dedicated interpreter, or sudo, on a machine
other people use.
task_for_pid¶
Does macOS grant this user the target's task port? SKIPPED off macOS.
sys.remote_exec must take the target's task port, and macOS hands that out
only to root or to a binary carrying the com.apple.system-task-ports
entitlement. Without it the attach fails with "Cannot get task port".
Rerun the probe under sudo, e.g. sudo pidprobe snap 12345. The entitlement
route needs a signed, entitled interpreter and is not something pidprobe can
grant itself. This check passes only when pidprobe already runs as root, so on
macOS expect to use sudo for every probe.
target_process¶
Does the pid exist, and may this user signal it?
The cheapest possible question, asked with signal 0: writing into a process is strictly more privileged than signalling it, so a refusal here settles the matter. It fails either because nothing holds that pid -- a target that already exited, or a pid that only means something inside a container -- or because the process exists and belongs to someone else.
Find the running target with pgrep -af python and pass its pid, or rerun
pidprobe as the user owning the target, or as root. A vanished target is also
what snap, eval and diff report with exit code
3, separately from a refused attach, because
nothing about the machine needs fixing.
target_owner¶
Do prober and target run as the same user? SKIPPED when the owner cannot
be determined.
Attaching writes into another process' memory, which the kernel permits only within one user unless the prober is privileged. A prober running as root passes whatever the target's owner is.
Rerun as that user -- sudo -u '#1001' pidprobe snap 12345 -- or as root.
pid_namespace¶
Is there a container boundary between prober and target? SKIPPED off
Linux, and when either namespace cannot be read.
The same number names different processes on the two sides of a namespace
boundary, and the debugger interface PEP 768 writes to cannot be reached
across it. A pid read from docker top and used on the host is the usual way
to arrive here.
Run pidprobe inside the container -- docker exec <container> pidprobe snap
<pid-inside> -- or join the namespace first with nsenter --target 12345
--pid --mount. Both require pidprobe to be reachable there, which for a
container without it usually means the nsenter route.
Note
On macOS and Windows this check is SKIPPED for a stronger reason than
"not implemented": a container there runs inside its own Linux kernel, so
a pid from it names nothing on the host and no amount of privilege makes
it reachable. Probe from inside the container.
target_python¶
Which Python does the target run? Reported as a WARN only when that
could not be established at all, in which case neither
target_python_version nor
target_python_match is reported.
A process cannot be asked for its interpreter version from the outside, so
pidprobe runs the target's own executable with -c to ask it. That only
happens when the binary's name identifies it as an interpreter (python*,
pypy*), so pointing doctor at an arbitrary pid never executes an arbitrary
program -- it warns here instead. The other ways to land here are a platform
that exposes no way to inspect another process (only Linux procfs and macOS
ps are supported), no executable reported for the pid, and an interpreter
that failed to answer.
Check by hand that the target runs the same CPython feature release as pidprobe. The warning does not block a probe: it means the two checks below could not be run, not that they would have failed.
target_python_version¶
Is the target a CPython new enough to carry the PEP 768 interface?
Fails when the target runs another implementation -- PyPy, GraalPy -- because
no other implementation exposes the debugger interface sys.remote_exec
writes to, and when it runs a CPython older than 3.14, where that interface
did not exist yet.
Restart the target on CPython 3.14 or newer, or use an out-of-process sampler such as py-spy, which reads the target's memory instead of asking it to run code and therefore works against older and non-CPython targets.
target_python_match¶
Do prober and target share a CPython feature release?
sys.remote_exec reaches the debugger interface through offsets that are only
stable within one CPython feature release, so it refuses a target built from a
different one. 3.14 probing 3.15 fails as surely as 3.13 does; patch releases
are fine.
Run pidprobe from an interpreter of the target's feature release, for example
with uv tool install --python 3.14 pidprobe, or install it into the target's
own environment with /usr/bin/python3 -m pip install pidprobe. Installing
pidprobe once per interpreter version is the normal way to live with this.
target_remote_debug¶
Was the target started with PYTHON_DISABLE_REMOTE_DEBUG? SKIPPED off
Linux, and when the target's environment cannot be read.
CPython turns the debugger interface off when PYTHON_DISABLE_REMOTE_DEBUG is
present at start-up, whatever it is set to -- the empty string counts -- so
the target then has nothing for pidprobe to write to.
Restart the target without the variable in its environment. It is read once at interpreter start-up and cannot be cleared from outside, so unsetting it in your own shell changes nothing about a process that is already running.
Note
Reading another process' environment needs procfs, which is why this is
SKIPPED everywhere but Linux, and why it may be skipped there too when
the target belongs to another user. A skipped check is not a passing one:
if the attach is refused with nothing else to explain it, check this by
hand.
Failures doctor cannot predict¶
A clean report means nothing in the environment blocks attaching. Three failures remain possible after that, and each has its own exit code:
| Symptom | Exit code | What it means |
|---|---|---|
target never reached a safe eval point within 5s |
5 |
The target only runs injected code at the next bytecode boundary. A process parked in a long syscall, blocked in a C extension, or idle in a thread that never returns to Python never gets there. Raise --timeout, or use an out-of-process sampler such as py-spy. |
snapshot failed inside pid ... |
6 |
The injected script raised as a whole -- for eval, usually a SyntaxError or NameError in the expression, which says nothing about whether attaching works. |
internal error: ... |
70 |
A bug in pidprobe. Re-run with --debug (or PIDPROBE_DEBUG=1) for the traceback and report it. |
A single collector that raises is not in that list: it costs its own section,
which comes back as null with the reason in meta.collectors, and the
command still exits 0. See meta.