The symptom
Inside an openSUSE Tumbleweed proot on a Galaxy S24 FE, ordinary commands failed at random:
$ git rev-list --count HEAD
Real-time signal 2
$ echo $?
164
It hit git, python3, rpm scriptlets, tar and sleep. Three things made it hard to reason about:
- It was not proot-specific. A plain app sandbox with no ptrace printed the same thing.
setsidandnohupnever helped. They guard against parent death; this is not a parent.- Retrying made it worse, not better. Every retry spawns more processes, and exposure tracks process count.
What the signal actually is
Android's bionic reserves real-time signals for internal use. From libc/platform/bionic/reserved_signals.h:
// 32 (__SIGRTMIN + 0) POSIX timers
// 33 (__SIGRTMIN + 1) libbacktrace
// 34 (__SIGRTMIN + 2) libcore
// 35 (__SIGRTMIN + 3) debuggerd
// 36 (__SIGRTMIN + 4) platform profilers (heapprofd, traced_perf)
// 37 (__SIGRTMIN + 5) coverage
So kernel signal 36 is BIONIC_SIGNAL_PROFILER. The naming confusion is a base mismatch: bionic reserves 32 and 33, so userspace displays kernel 36 as SIGRTMIN+2 — “Real-time signal 2”. One signal, two numbering bases.
The sender is on the device. /system/etc/init/traced_perf.rc says it outright:
# KILL capability for sending BIONIC_SIGNAL_PROFILER.
service traced_perf /system/bin/traced_perf
user nobody
group nobody readproc readtracefs
capabilities KILL DAC_READ_SEARCH
traced_perf signals a process to walk its stacks. The default disposition of a real-time signal is terminate, and a native binary that never installs a handler for 36 dies on receipt. Bash reports 128 + 36 = 164.
setsid changes nothing because the sender is not the parent; and the rate drifts because sampling windows come and go.Measurements: risk is a step function, then a plateau
All probes ran as children of an agent shell, with sleep (≈zero CPU) so CPU time is ruled out as the variable.
| lifetime | killed | rate |
|---|---|---|
/bin/true | 0/30 | 0% |
sleep 0.001 | 0/40 | 0% |
sleep 0.01 | 0/40 | 0% |
sleep 0.05 | 1/40 | 2% |
sleep 0.2 | 6/40 | 15% |
sleep 1.0 | 2/25 | 8% |
sleep 3.0 | 3/25 | 12% |
Below ~20 ms a process is effectively immune. Between 20 ms and 200 ms risk climbs steeply. Past ~200 ms it plateaus at 10–15% and stops climbing — sleep 3.0 is no more at risk than sleep 0.2.
That also means the rate is not a constant. The same command measured 17% in one batch and 3% minutes later. Never quote a kill rate from a single run, and never read one clean batch as “fixed”.
The fix, and where the guard has to live
SIG_IGN is inherited across fork+exec and — unlike a handler — cannot be reset by the child. So one line protects every descendant:
trap '' 36
Two ways to apply it, measured at N=100 each:
| command | default | trap '' 36 inline | guard via .profile |
|---|---|---|---|
sleep 0.2 | 13/100 (13%) | 0/100 | 3/100 (3%) |
git rev-list --count HEAD | 10/100 (10%) | 0/100 | — |
python3 -c print | 3/100 (3%) | 0/100 | — |
Aggregate across the earlier A/B: 10/240 default → 1/240 protected. Inline: 0/300.
The .profile route is only partial, and the reason is structural: a shell must start before it sources .profile, and during that startup window signal 36 still has its default disposition. The guard belongs at the top of every script that matters; .profile is a convenience backstop for interactive shells.
The rule that matters: exposure = spawn count
Per-process risk is roughly flat in duration, so making each process longer buys nothing. Making fewer processes buys everything.
A maintenance script spawning ~60 processes per run, at p≈0.12, has 1 − 0.88⁶⁰ ≈ 99.97% chance of at least one kill — effectively guaranteed every run. At 5 spawns it is ~47%.
Batch probes. One python3 that does 30 checks beats 30 shell commands.
I moved all data collection in the lab's map generator into a single Python process: os.stat instead of stat+cut, one SQLite connection for all counts, shutil.disk_usage instead of df. Spawns dropped from ~60 to ~8. That raises the odds of a clean run; it does not make the kills disappear, so the outer retry stays.
What this does not prove
- I proved that protecting against 36 removes the kills (0/300). I did not capture the sender in a live trace.
traced_perfis identified from three independent sources — AOSP'sreserved_signals.h, the device's owntraced_perf.rcnamingBIONIC_SIGNAL_PROFILER, and the daemon running withcapabilities KILL— not from strace of the kill itself. - The 3/100 residual under
.profileis explained by the startup window, but a second, rarer sender is not ruled out. - Both
persist.traced_perf.enableandtraced.lazy.traced_perfread empty, yet the daemon runs. Unexplained — possibly a vendor start path. - Stopping the service needs root (
stop traced_perfreturns EPERM from shell), so the guard is the practical mitigation.
Reproducing it
# is the profiler running?
adb shell ps -A -o PID,PPID,NAME,ARGS | grep -E 'traced_perf|heapprofd'
adb shell getprop persist.traced_perf.enable
adb shell getprop traced.lazy.traced_perf
# A/B the guard, isolating each config so one kill does not lose the batch
for i in $(seq 100); do sleep 0.2; done; echo "rc=$?" # default
( trap '' 36; for i in $(seq 100); do sleep 0.2; done ); echo "rc=$?"
Isolate each configuration in its own process. When the driver is killed, the log stays 0 bytes — indistinguishable from “never ran”. That mistake cost me two batches.
It is packaged
The patterns that survived this are in durable-sh: signal immunity, atomic writes (tmp + mv, so a kill leaves a missing file instead of a truncated live one), parent-shell retries, and git index.lock recovery. MIT, 18/18 tests, tagged releases.