Android · debugging

Signal 36: the Android profiler that kills your shell scripts

Roughly one in eight short-lived processes on my phone died with exit 164 and “Real-time signal 2”. It was not proot, not memory pressure, and not a bug in my scripts. It was the platform profiler.

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:

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.

Why this explains everything else: processes under ~20 ms finish before a profiling window opens; longer ones get caught; 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.

lifetimekilledrate
/bin/true0/300%
sleep 0.0010/400%
sleep 0.010/400%
sleep 0.051/402%
sleep 0.26/4015%
sleep 1.02/258%
sleep 3.03/2512%

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:

commanddefaulttrap '' 36 inlineguard via .profile
sleep 0.213/100 (13%)0/1003/100 (3%)
git rev-list --count HEAD10/100 (10%)0/100—
python3 -c print3/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

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.