跪拜 Guibai
← Back to the summary

A Samsung OneUI Bug Leaked `top` Processes for Years, Crashing Banking and Government Apps Across China

Content Introduction, Background

This time, the BUG I discovered in Samsung's OneUI system has a wide impact and has existed for a long time. I think there are several reasons:

This is also why I finally have an analysis record worth sharing. The last blog post I wrote analyzing a system BUG was about Xiaomi MIUI's setSystemGestureExclusionRects not taking effect, which was two years ago.


This BUG was first encountered in the UnionPay (云闪付) app. To receive a national subsidy, I had to download and register, but when I received a verification code and pulled down the status bar to check it, the UnionPay app just crashed directly. On the in-app customer service page, I wanted to send a screen recording, and it crashed again after selecting the file. The last message from the in-app customer service was asking if my phone had software like Xposed installed. I absolutely did not have it installed. But with the frequent crashes, I had no mind to reply to the customer service in the app.

It's really frustrating to miss out on a national subsidy for essential purchases, feeling like a hard loss of several hundred yuan. Recalling that the Ministry of Industry and Information Technology (MIIT) often reports apps for illegally collecting information, I first reported this issue to the MIIT and got a response that they are not responsible for this aspect. I also found the customer service number for China UnionPay inside the UnionPay app. A few days after reporting to UnionPay customer service, I received a callback, and I heard the UnionPay developer on the other end of the line asking the customer service to ask me if I had software like Xposed installed. A few days later, the customer service approved my WeChat request, I sent over a screen recording, and the customer service gave me a 10 yuan red packet as compensation.

Later, I encountered crash problems in other apps, but this app was similar in nature to UnionPay. I still just suspected whether they had some common SDK with issues (just like some apps detect frida, but it's actually a certain OAID-related library libmsaoaidsec.so detecting frida). Later, I heard they didn't have a unique common SDK, and then I discovered their common feature was that they all used the enterprise version of Bangcle (梆梆) reinforcement. I found a few more samples of the Bangcle enterprise reinforcement and confirmed it was indeed related to this, suspecting that Bangcle's environment detection logic had a false positive on Samsung phones.

But the phenomenon was not quite like previously encountered triggers of reinforcement shell detection. My impression was that when Bangcle's reinforcement shell detects an abnormal environment, it generally crashes at the native layer and sets certain registers to error codes, making it easier for their reinforcement vendor to troubleshoot and add to whitelists, etc. Some shells directly kill their own process, or kill their own process after a fixed 10-second delay. But with these apps, if you try multiple times, say 10 times, there will be one time it doesn't crash, and then pulling down the status bar or switching the app to the background and foreground makes it crash again.

There are some folk remedies online, such as turning off RAM Plus, or entering developer options and enabling "Disable child process restrictions". These can indeed solve the problem, but the former only temporarily fixes it, and it will crash again not long after. It just temporarily reduces storage space usage + restarts the phone, temporarily avoiding the BUG logic. The latter only masks the problem; it indeed no longer crashes, but the BUG is still consuming system resources. Soon there will be hundreds, even thousands of meaningless processes silently wasting system resources in the background, even causing system process exceptions due to excessive file descriptor usage, leading to the phone suddenly black-screening and restarting. There are some other folk remedies not mentioned here, but they are just accidental hits. Only this article truly analyzes the root cause for the first time.

There were quite a few detours in troubleshooting this problem:

To test if it could be reproduced on more devices, I went to two Samsung experience stores. One, upon learning I wanted to test low storage space conditions, directly said, "All phone brands can't open software when there's 3GB remaining, it's not a Samsung problem." Afraid I would find a BUG, they directly advised me to leave, saying I would "waste time here and affect others' experience." Then I went to another experience store 5 kilometers away. Just as I achieved the trigger condition of having less than 3GB of remaining storage space, I discovered the display phone couldn't change the phone's time. Without changing the time, naturally reproducing this bug would take at least about 3 hours, and once the screen turned off, the experience store's display phone would restore data, making everything futile. The time cost was too high. Then, although cloud testing platforms were free, the test devices inside were all overseas versions, and from the code, only the mainland version would trigger this BUG.

However, I finally found a cloud testing platform with a mainland version. I found a OneUI 8.5 device, extracted the relevant files, and confirmed that Samsung has fixed this problem in the OneUI 8.5 version. But the BUG code belongs to the apex module, and the version of the apex module does not necessarily correspond exactly to the system version. Perhaps lower OneUI versions will also receive OTA updates for the relevant module later.

Finding Clues from Logs

The next steps will switch to another app: using Railway 12306 (铁路12306) for demonstration.

Don't filter logcat, release logs for all levels, all tags, all package names, search for the package name about to crash, such as Railway 12306's com.MobileTicket, and look backwards from the last one.

image.png

image.png

Here AMS just says the process exited, but this AMS log does not contain the reason for the process exit. However, based on this log, we can determine the pid before exit was 1760. Continue searching for 1760.

image.png

Searching backwards, you can see the reason the process was terminated is

Killing PhantomProcessRecord {c4b77f2 1815:1760:com.MobileTicket/u0a295}: Trimming phantom processes

The process was actively terminated by AMS. A keyword is mentioned here: phantom processes. This article will uniformly refer to them as "phantom processes". It mentions "Trimming phantom processes", and the following text will search in AOSP based on this to elaborate on the specific cleanup logic.


For a regular Android application app to create a process, there are two ways. One is to declare a processName for a component in the manifest. The other is to fork a child process, commonly used by reinforcement shells or when needing to quickly copy a block of memory (like xcrash does when capturing a crash). It's also used together with execve, such as executing a command, which is essentially fork+execve. For example, a common log capturing solution is to use Runtime.exec or ProcessBuilder to start logcat.

A child process created by an app process through fork is a phantom process. Note that this refers to regular application apps, so the regular app process created by Zygote calling fork is not defined as a phantom process.

The Bangcle enterprise reinforcement shell creates child processes through fork to execute detection-related code. If the process created by fork is terminated, the protected app process will also exit accordingly.

processName in the manifest is declared for the four major components, so AMS can inherently manage their lifecycle. However, processes created by calling fork in the native layer cannot be directly perceived by the framework and are not easy to manage. To prevent abuse, Android 12 started limiting the number of phantom processes, with a default of 32.

After entering adb shell, using dumpsys activity processes can print out the current phantom processes. Executing this on a phone where the crash can be reproduced:

e1q:/ $
e1q:/ $ dumpsys activity processes | grep -A 5 "PhantomProcessRecord"
    proc #0: PhantomProcessRecord {ec6ced2 1743:2588:top/1000}
      user #0 uid=1000 pid=1743 ppid=2588 knownSince=-22m26s620ms killed=false
      lastCpuTime=0 oom adj=-900 seq=3963
    proc #1: PhantomProcessRecord {6163ba3 2655:2588:top/1000}
      user #0 uid=1000 pid=2655 ppid=2588 knownSince=-10h16m30s253ms killed=false
      lastCpuTime=10 timeUsed=+60ms oom adj=-900 seq=3963
    proc #2: PhantomProcessRecord {b53ea0 4792:2588:top/1000}
      user #0 uid=1000 pid=4792 ppid=2588 knownSince=-8h19m1s625ms killed=false
      lastCpuTime=10 timeUsed=+40ms oom adj=-900 seq=3963
    proc #3: PhantomProcessRecord {6009a59 5690:2588:top/1000}
      user #0 uid=1000 pid=5690 ppid=2588 knownSince=-13h28m38s82ms killed=false
      lastCpuTime=10 timeUsed=+100ms oom adj=-900 seq=3963
    proc #4: PhantomProcessRecord {71f061e 8009:2588:top/1000}
      user #0 uid=1000 pid=8009 ppid=2588 knownSince=-6h16m7s358ms killed=false
      lastCpuTime=10 timeUsed=+20ms oom adj=-900 seq=3963
    proc #5: PhantomProcessRecord {546eeff 8181:2588:top/1000}
      user #0 uid=1000 pid=8181 ppid=2588 knownSince=-5h7m32s244ms killed=false
      lastCpuTime=10 timeUsed=0 oom adj=-900 seq=3963
    proc #6: PhantomProcessRecord {43fa4cc 8980:2588:top/1000}
      user #0 uid=1000 pid=8980 ppid=2588 knownSince=-4h37m15s947ms killed=false
      lastCpuTime=0 oom adj=-900 seq=3963
    proc #7: PhantomProcessRecord {5178315 10626:2588:top/1000}
      user #0 uid=1000 pid=10626 ppid=2588 knownSince=-13h51m27s689ms killed=false
      lastCpuTime=10 timeUsed=+90ms oom adj=-900 seq=3963
    proc #8: PhantomProcessRecord {c31662a 12436:2588:top/1000}
      user #0 uid=1000 pid=12436 ppid=2588 knownSince=-13h9m24s922ms killed=false
      lastCpuTime=10 timeUsed=+90ms oom adj=-900 seq=3963
    proc #9: PhantomProcessRecord {ca57c1b 14163:2588:top/1000}
      user #0 uid=1000 pid=14163 ppid=2588 knownSince=-8h52m42s943ms killed=false
      lastCpuTime=10 timeUsed=+50ms oom adj=-900 seq=3963
    proc #10: PhantomProcessRecord {62841b8 15184:2588:top/1000}
      user #0 uid=1000 pid=15184 ppid=2588 knownSince=-10h42m34s355ms killed=false
      lastCpuTime=10 timeUsed=+60ms oom adj=-900 seq=3963
    proc #11: PhantomProcessRecord {f7a3b91 15367:2588:top/1000}
      user #0 uid=1000 pid=15367 ppid=2588 knownSince=-11h53m21s647ms killed=false
      lastCpuTime=10 timeUsed=+80ms oom adj=-900 seq=3963
    proc #12: PhantomProcessRecord {857baf6 16294:2588:top/1000}
      user #0 uid=1000 pid=16294 ppid=2588 knownSince=-5h1m13s120ms killed=false
      lastCpuTime=10 timeUsed=0 oom adj=-900 seq=3963
    proc #13: PhantomProcessRecord {895bef7 17702:2588:top/1000}
      user #0 uid=1000 pid=17702 ppid=2588 knownSince=-9h18m23s947ms killed=false
      lastCpuTime=10 timeUsed=+60ms oom adj=-900 seq=3963
    proc #14: PhantomProcessRecord {1fbc164 17916:2588:top/1000}
      user #0 uid=1000 pid=17916 ppid=2588 knownSince=-12h26m38s906ms killed=false
      lastCpuTime=10 timeUsed=+70ms oom adj=-900 seq=3963
    proc #15: PhantomProcessRecord {475ffcd 18682:2588:top/1000}
      user #0 uid=1000 pid=18682 ppid=2588 knownSince=-5h49m46s190ms killed=false
      lastCpuTime=10 timeUsed=+20ms oom adj=-900 seq=3963
    proc #16: PhantomProcessRecord {fbc9082 19708:2588:top/1000}
      user #0 uid=1000 pid=19708 ppid=2588 knownSince=-7h4m42s286ms killed=false
      lastCpuTime=10 timeUsed=+20ms oom adj=-900 seq=3963
    proc #17: PhantomProcessRecord {ddd5393 19949:2588:top/1000}
      user #0 uid=1000 pid=19949 ppid=2588 knownSince=-11h31m48s133ms killed=false
      lastCpuTime=10 timeUsed=+60ms oom adj=-900 seq=3963
    proc #18: PhantomProcessRecord {d658fd0 20100:2588:top/1000}
      user #0 uid=1000 pid=20100 ppid=2588 knownSince=-13h41m24s354ms killed=false
      lastCpuTime=10 timeUsed=+100ms oom adj=-900 seq=3963
    proc #19: PhantomProcessRecord {efcbc9 20546:2588:top/1000}
      user #0 uid=1000 pid=20546 ppid=2588 knownSince=-10h59m58s866ms killed=false
      lastCpuTime=10 timeUsed=+60ms oom adj=-900 seq=3963
    proc #20: PhantomProcessRecord {ca732ce 20678:2588:top/1000}
      user #0 uid=1000 pid=20678 ppid=2588 knownSince=-6h9m4s943ms killed=false
      lastCpuTime=10 timeUsed=+10ms oom adj=-900 seq=3963
    proc #21: PhantomProcessRecord {dff95ef 22114:2588:top/1000}
      user #0 uid=1000 pid=22114 ppid=2588 knownSince=-8h25m48s257ms killed=false
      lastCpuTime=10 timeUsed=+40ms oom adj=-900 seq=3963
    proc #22: PhantomProcessRecord {b5bd8fc 22234:2588:top/1000}
      user #0 uid=1000 pid=22234 ppid=2588 knownSince=-4h55m37s266ms killed=false
      lastCpuTime=0 oom adj=-900 seq=3963
    proc #23: PhantomProcessRecord {1205b85 22569:2588:top/1000}
      user #0 uid=1000 pid=22569 ppid=2588 knownSince=-12h2m43s261ms killed=false
      lastCpuTime=10 timeUsed=+70ms oom adj=-900 seq=3963
    proc #24: PhantomProcessRecord {487adda 25387:2588:top/1000}
      user #0 uid=1000 pid=25387 ppid=2588 knownSince=-12h16m17s235ms killed=false
      lastCpuTime=10 timeUsed=+70ms oom adj=-900 seq=3963
    proc #25: PhantomProcessRecord {559a20b 26665:2588:top/1000}
      user #0 uid=1000 pid=26665 ppid=2588 knownSince=-10h52m53s85ms killed=false
      lastCpuTime=10 timeUsed=+70ms oom adj=-900 seq=3963
    proc #26: PhantomProcessRecord {60b88e8 26831:2588:top/1000}
      user #0 uid=1000 pid=26831 ppid=2588 knownSince=-5h44m9s538ms killed=false
      lastCpuTime=10 timeUsed=+20ms oom adj=-900 seq=3963
    proc #27: PhantomProcessRecord {2102b01 27440:2588:top/1000}
      user #0 uid=1000 pid=27440 ppid=2588 knownSince=-6h25m40s471ms killed=false
      lastCpuTime=10 timeUsed=+20ms oom adj=-900 seq=3963
    proc #28: PhantomProcessRecord {1c2cda6 28494:2588:top/1000}
      user #0 uid=1000 pid=28494 ppid=2588 knownSince=-9h0m16s439ms killed=false
      lastCpuTime=10 timeUsed=+40ms oom adj=-900 seq=3963
    proc #29: PhantomProcessRecord {efe53e7 30564:2588:top/1000}
      user #0 uid=1000 pid=30564 ppid=2588 knownSince=-4h49m9s391ms killed=false
      lastCpuTime=10 timeUsed=0 oom adj=-900 seq=3963
    proc #30: PhantomProcessRecord {b844b94 31655:2588:top/1000}
      user #0 uid=1000 pid=31655 ppid=2588 knownSince=-12h39m2s871ms killed=false
      lastCpuTime=10 timeUsed=+80ms oom adj=-900 seq=3963
    proc #31: PhantomProcessRecord {ed3763d 31761:2588:top/1000}
      user #0 uid=1000 pid=31761 ppid=2588 knownSince=-11h19m4s341ms killed=false
      lastCpuTime=120 timeUsed=+70ms oom adj=-900 seq=3963

  Foreground Processes:
    PID #12330: ImportanceToken { 2468a7a setProcessImportant() 12330 android.os.BinderProxy@a96522b }
e1q:/ $

It can be found that a bunch of top processes appear, and the knownSince field shows no periodic pattern in the startup time. It seems that top processes are more likely to be created during phone usage. The ppid is actually all 2588, which on my device is the system_server process, and the uid is also the same as system_server, 1000.

image.png

I wrote a Demo here and found that after forking, dumpsys activity processes | grep -A 5 "PhantomProcessRecord" does not immediately print out the process I forked. It's only when pulling down the phone's status bar that the system counts it, indicating that AMS counts phantom processes only at certain times. The system only performs cleanup and recycling when it discovers the number of phantom processes has reached the limit. This is why it crashes when pulling down the status bar, which also makes the phenomenon look too much like a bug in the app itself.

If "Disable child process restrictions" is checked in developer options, it indeed no longer crashes. All apps can be opened and used normally, and soon the number of top processes will exceed 32. There was a pitfall during debugging here: if you want to reproduce this BUG again, after turning off this option again, you need to restart the phone, otherwise the number of phantom processes will no longer be recycled. So there's a better test command:

device_config set_sync_disabled_for_tests persistent
device_config put activity_manager max_phantom_processes 64

After raising the phantom process limit, the crash problem temporarily disappears. At this point, it can be preliminarily determined that it is a system BUG. Everything makes sense. The system_server process has a very low oom_adj, so its cleanup priority is very low. When the system cleans up processes, it cleans up based on oom_adj, so it's actually the regular app processes that get cleaned up. The specific cleanup logic will be mentioned below. But at this point, it's still not possible to determine the specific trigger timing for the top process leak, and why the top process leak occurs, so further analysis is needed.

Attempting to Find the Timing of Top Process Creation

The timing of top process creation is not easy to determine. You can execute a script in adb shell, and then while using the phone normally, pay attention to which of our operations trigger the creation of top processes.

p=" "; while true; do for i in $(pgrep -x -u 1000 top; pgrep -x -P 2588 top); do case "$p" in *" $i "*) ;; *) T=$(date "+%Y-%m-%d %H:%M:%S.%N" | cut -c1-23); echo "$T $i"; p="$p$i ";; esac; done; sleep 0.5; done

Where 2588 is the system_server process pid.

More information was collected through this method: Cold starting an app from the launcher, or installing a new app, triggers the creation of a top process. However, it's not created every time, but rather with an interval of several minutes.

Decompiling system_server, Static Analysis

To guess why system_server executes the top command, you can look at the parameters when top starts. Even if the device is not rooted, you can determine the command-line parameters when the app starts through /proc/[pid]/cmdline. You can see the command and parameters are top -b -n 1:

image.png

(Later it was discovered that the command top -b -n 1 itself can also show the command-line parameters of the top process).

Use adb pull to export the phone's /system/framework folder. Since the Java code decompiled by jadx might be incorrect, fail to decompile, or be incomplete, baksmali is used here to convert all jars into smali code. The smali version is taken as the standard. Searching for where the "top" process is called, the main search keywords are ProcessBuilder, Runtime.exec, top.

Then it was found that there is indeed no place that starts the top process. All parameters calling exec were traced to their sources, confirming no place calls top. Several possibilities here: the relevant code is obfuscated, system_server also loads jar/dex from other locations, or the logic is in the native layer. However, without root permission, it's difficult to intuitively know which dex/jar/so the system_server process loads.

Based on some past accumulation, it's known that the device's /apex directory also stores some modules containing dex/so. Starting from Android 10, the apex mechanism was introduced, placing some modules in the /apex directory instead of /system/framework. The original intention was to facilitate dynamic upgrades of certain system modules, just like upgrading apps in an app store, without having to upgrade the entire phone operating system.

But without root permission, it's also impossible to directly use ls to get the /apex directory listing of the current device. The dex files in the /system/apex directory are inside img format files, but there's no need to consider how to handle this img file here; there's a quicker trick. Both system_server and regular app processes are forked from the zygote process, and most modules are loaded as early as the zygote process. Without writing code, directly use run-as for a debuggable app in adb shell, then print the corresponding maps file, as shown below. Through this method, first determine the paths of some modules already loaded by the zygote process, then export the known modules first, and continue decompiling to check if there are any calls related to top.

image.png

image.png

However, the key information for executing the top command was still not found. But as shown above, we saw a Samsung device-specific apex path /apex/com.samsung.android.shell/. Although /apex/ cannot be directly ls'd for adb shell, its subdirectories can be, as shown below. This is how the key file containing the BUG was found: service-samsung-shell.jar.

image.png

This is how the location calling the top command was finally found:

image.png

3 Serious Bugs in 1 Function

After cross-referencing with the smali code, it was determined that the pseudo-code decompiled by jadx is basically accurate. It's posted directly here:

private double getCpuUsage(String processName) {
    double result = 0.0d;
    try {
        Process process = Runtime.getRuntime().exec("top -b -n 1");
        BufferedReader in1 = new BufferedReader(new InputStreamReader(process.getInputStream()));
        int cnt = 8;
        while (true) {
            String str1 = in1.readLine();
            if (str1 == null) {
                break;
            }
            int cnt2 = cnt - 1;
            if (cnt <= 0) {
                break;
            }
            if (processName == null) {
                if (!str1.contains("%idle")) {
                    cnt = cnt2;
                } else {
                    String cpuTotal = str1.split("%cpu")[0];
                    String cpuIdle = str1.split(" +")[4].split("%")[0];
                    result = (Double.valueOf(cpuTotal).doubleValue() - Double.valueOf(cpuIdle).doubleValue()) / Double.valueOf(cpuTotal).doubleValue();
                    break;
                }
            } else if (!str1.contains(processName)) {
                cnt = cnt2;
            } else {
                result = Double.valueOf(str1.split(" +")[9]).doubleValue() / 100.0d;
                break;
            }
            return result;
        }
    } catch (IOException e) {
        Log.e(this.TAG, "Error getCpuUsage: \n", e);
    }
    return result;
}

The output result of executing top -b -n 1 on my Android 16 device:

image.png

BUG 1

Note that the code uses str1.split(" +")[9] to get the 10th column. However, the 1st column is PID, the 10th column is MEM, and the 9th column is CPU! This results in a completely incorrect CPU percentage being obtained.

Perhaps before a certain version of Android 16, this wasn't a BUG. Maybe it's a historical leftover issue. Perhaps it was normal in Android 12 or 13, but I don't think it's necessary to test that here. Anyway, this is definitely a very bad practice. Why assume the output format of the top command will never change?

Recalling a Previous Experience

I recalled a very bad experience from a previous job, where the leader insisted on using the sha256sum command to get the hash value of a file, because the leader said, "Linux commands are extremely efficient, they should be more efficient than your Java!" Since the leader hadn't systematically studied computer science and didn't even understand the relationship between processes and threads, it was impossible to explain, and they wouldn't listen to explanations.

A former colleague was also ordered to use the system's built-in tar command to implement file compression. Because some vivo systems had modified the parameters of the tar command, it caused functional abnormalities for some users, but our own test devices couldn't reproduce it. Due to the low proportion of feedback, no one paid attention to it. Ultimately, it was left unresolved as these phone models were phased out. Let's not expand on these bad experiences... .

BUG 2 (Focus of this article)

Here, Runtime.getRuntime().exec is used to create a child thread, and BufferedReader is used to read the output result. However, because cnt = 8, the output of the child process is never fully read before the function exits. The child process is still running, without calling destroy or close. This is the root cause of the top process leak, which ultimately leads to app exceptions and even mass app crashes.

If a user checks "Disable child process restrictions" based on online tutorials, although the crash problem is superficially solved, the top process leak still exists, and the file descriptor leak still exists. The Android system limits the number of file descriptors for a single process, usually 1024. If this number is exceeded, it will cause a system_server process exception, leading to the phone suddenly black-screening and restarting.

BUG 3

Even without BUG 1, it's fundamentally impossible to read the CPU usage of the target process! Here cnt is 8. However, according to the output on my device, besides reading the top process itself, it can only additionally read the CPU usage of one process, and it's very difficult for that to be exactly the target process!

A Stable Test Path to Trigger the BUG

At this step, it's very easy to find a guaranteed reproduction path for the BUG.

Earlier, it was found that cold starts trigger it, and occasionally it's triggered. This is indeed the case. One of the chains is sorted out as follows. Because Samsung has done some customization, the analysis here is based on the framework source code exported from the device, not AOSP source code.

Classic interview question, App cold start process: User clicks an icon on the Launcher, ActivityStarter.execute internally calls startActivityUnchecked, which calls resolveReusableTask, gets the PkgPredictorService service, and calls dexFilePreload:

image.png

Directly going to the jar found earlier, dexFilePreload internally calls appTouchDownEvent:

image.png

Then internally uses a handler to call handleAppBoostTask:

image.png

handleAppBoostTask internally calls isSystemBusy, as shown below,

image.png

Where isSystemBusy calls getCpuUsage, thus closing the BUG loop, as shown below. Also, as expected, there is a place that triggers its call when an app is installed, as shown above.

image.png

Posted here:

private long getAvailRomSize() {
    File file = Environment.getDataDirectory();
    StatFs statFs = new StatFs(file.getPath());
    long blockSize = statFs.getBlockSize();
    long availableBlocks = statFs.getAvailableBlocksLong();
    long available = (((availableBlocks * blockSize) / 1024) / 1024) / 1024;
    this.SYSTEM_BUSY_UPDATE_TIME = 30000 * available;
    if (this.SYSTEM_BUSY_UPDATE_TIME < 300000) {
        this.SYSTEM_BUSY_UPDATE_TIME = 300000L;
    }
    return available;
}

public boolean isSystemBusy() {
    if (!this.mAppBoostInit) {
        return false;
    }
    if (System.currentTimeMillis() - this.mSystemBusyTime <= this.SYSTEM_BUSY_UPDATE_TIME) {
        return this.mSystemBusy;
    }
    this.mSystemBusyTime = System.currentTimeMillis();
    if (getAvailRomSize() <= this.APPBOOST_AVAILABLE_ROM_SIZE && getCpuUsage("com.google.android.providers.media.module") >= this.MP_CPU_LIMITATION) {
        this.mSystemBusy = true;
        return true;
    }
    this.mSystemBusy = false;
    return false;
}

This function uses currentTimeMillis to cache the last judgment result. This validity period is dynamically calculated. In getAvailRomSize, the cache validity period is updated, using the number of GB of available phone space multiplied by 30000 milliseconds (30 seconds). That is, for a user with 10GB remaining, the cache is 300 seconds. However, there is also a minimum threshold here of 300000 milliseconds (300 seconds, 5 minutes), meaning the getCpuUsage function will be called at most once every 5 minutes. But for our testing convenience, we can directly go to system settings and change the phone time. However, the display phones in Samsung experience stores cannot change the time, and it's not easy to enter settings on cloud testing devices.

image.png

It's not that getCpuUsage will definitely be called at a minimum of 5 minutes. There is another restriction here: the available ROM space (in GB) must be less than APPBOOST_AVAILABLE_ROM_SIZE, which is a constant value of 3 here. This means only users with less than 3GB of memory will experience the top process leak causing many apps to crash or behave abnormally.

So the stable reproduction path: Do not check "Disable child process restrictions" in developer options (if checked, uncheck it, then restart the phone). Then adjust the available memory space to below 3GB. Then cold start an app about 32 times, with a 5-minute interval each time (you can shorten the interval by modifying the phone's system time).

There is another prerequisite: it must be the Chinese version of the system:

image.png

Inside dexFilePreload, there is no chance to call appTouchDownEvent, so this BUG will not be triggered, because in non-Chinese version systems, mIpmAntiAgingController is not assigned:

image.png

Android 16 Phantom Process Cleanup Order

When I initially mentioned this problem to a colleague, a colleague said: "...I don't think it's likely a Samsung system BUG. When an app is in the foreground, doesn't the system prioritize recycling background processes when reclaiming processes? According to what you're saying, is there a BUG in the Android system? That's unlikely..."

Let's look directly at the code. First, look at the log mentioned earlier, located in the trimPhantomProcessesIfNecessary function:

Source: https://cs.android.com/android/platform/superproject/+/android-16.0.0_r4:frameworks/base/services/core/java/com/android/server/am/PhantomProcessList.java

image.png

/**
 * Clamp the number of phantom processes to
 * {@link ActivityManagerConstants#MAX_PHANTOM_PROCESSE}, kills those surpluses in the
 * order of the oom adjs of their parent process.
 */
void trimPhantomProcessesIfNecessary() {
    if (!mService.mSystemReady || !FeatureFlagUtils.isEnabled(mService.mContext,
            SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS)) {
        return;
    }
    synchronized (mService.mProcLock) {
        synchronized (mLock) {
            mTrimPhantomProcessScheduled = false;
            if (mService.mConstants.MAX_PHANTOM_PROCESSES < mPhantomProcesses.size()) {
                for (int i = mPhantomProcesses.size() - 1; i >= 0; i--) {
                    mTempPhantomProcesses.add(mPhantomProcesses.valueAt(i));
                }
                synchronized (mService.mPidsSelfLocked) {
                    Collections.sort(mTempPhantomProcesses, (a, b) -> {
                        final ProcessRecord ra = mService.mPidsSelfLocked.get(a.mPpid);
                        if (ra == null) {
                            // parent is gone, this process should have been killed too
                            return 1;
                        }
                        final ProcessRecord rb = mService.mPidsSelfLocked.get(b.mPpid);
                        if (rb == null) {
                            // parent is gone, this process should have been killed too
                            return -1;
                        }
                        if (ra.getCurAdj() != rb.getCurAdj()) {
                            return ra.getCurAdj() - rb.getCurAdj();
                        }
                        if (a.mKnownSince != b.mKnownSince) {
                            // In case of identical oom adj, younger one first
                            return a.mKnownSince < b.mKnownSince ? 1 : -1;
                        }
                        return 0;
                    });
                }
                for (int i = mTempPhantomProcesses.size() - 1;
                        i >= mService.mConstants.MAX_PHANTOM_PROCESSES; i--) {
                    final PhantomProcessRecord proc = mTempPhantomProcesses.get(i);
                    proc.killLocked("Trimming phantom processes", true);
                }
                mTempPhantomProcesses.clear();
            }
        }
    }
}

Look at the Collections.sort here. It directly sorts based on the oom adj of mPpid (the parent process's pid), then starts killing from the end with the larger oom adj until the number of phantom processes is less than MAX_PHANTOM_PROCESSES. system_server's oom adj is defined as -900, which can also be seen from the previous adb output. For regular apps, the foreground oom adj is 0, visible non-foreground is 100, the previous visible app is 700. A regular app's oom adj cannot be smaller than 0 no matter what.

Additionally, from the source code, there is another situation where the parent process is terminated together. The code is as follows, but this will not be explored further here.

/**
 * Kill the given phantom process, all its siblings (if any) and their parent process
 */
@GuardedBy("mService")
void killPhantomProcessGroupLocked(ProcessRecord app, PhantomProcessRecord proc,
        @Reason int reasonCode, @SubReason int subReason, String msg) {
    synchronized (mLock) {
        int index = mAppPhantomProcessMap.indexOfKey(proc.mPpid);
        if (index >= 0) {
            final SparseArray<PhantomProcessRecord> array =
                    mAppPhantomProcessMap.valueAt(index);
            for (int i = array.size() - 1; i >= 0; i--) {
                final PhantomProcessRecord r = array.valueAt(i);
                if (r == proc) {
                    r.killLocked(msg, true);
                } else {
                    r.killLocked("Caused by sibling process: " + msg, false);
                }
            }
        }
    }
    // Lastly, kill the parent process too
    app.killLocked("Caused by child process: " + msg, reasonCode, subReason, true);
}

OneUI 8.5 Fix

As shown in the image, the CPU usage is obtained using the proc filesystem method, no longer using the top command.

image.png

Actually, it's not quite finished yet, and I haven't had time to study what IPM is doing here. It's too late, I'll add more details when I have time. I'll just publish it like this for now. For technical exchanges or other matters, you can contact me on WeChat at HKHSTECH (the WeChat ID might change, but it won't change for a few weeks).

Comments

Top 2 from juejin.cn, machine-translated. The original thread is authoritative.

厮年断弦

Same nine years of compulsory education, why are you so outstanding?

菲尼克斯大大

Impressive. I always thought it was the apps' fault, since other apps don't crash. [thumbs up]