Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,34 @@ abstract class BaseEditorActivity :
resetMemUsageChart()
}

/**
* Plots the Gradle daemon, reported by the tooling server once a build has spawned it.
*
* The daemon is the largest of the three watched processes -- larger than the IDE and the
* tooling server together on a Compose project -- and it is the likeliest reason a build is slow
* or is killed on a small device. Until ADFA-5514 it was the one process the chart did not show.
*/
fun watchGradleDaemon(pid: Int) {
memoryUsageWatcher.watchProcess(pid, PROC_GRADLE_DAEMON)
resetMemUsageChart()
}

/**
* Stops plotting the Gradle daemon [pid], which has exited.
*
* Not on build finish: a daemon outlives the build that spawned it and goes on holding its heap
* while idle, which is the number worth showing on a device that is short of memory.
*
* By pid rather than by name, so a late exit cannot take out its successor's line. Removing "the
* Gradle daemon" would: a daemon that dies as the next build starts one is two reports racing
* over one row, and [watchProcess]'s `unique` has already dropped the old pid by then, so this
* is a no-op in exactly the case where the name would have been wrong.
*/
fun unwatchGradleDaemon(pid: Int) {
memoryUsageWatcher.unwatchProcess(pid)
resetMemUsageChart()
}

protected fun resetMemUsageChart() {
val processes = memoryUsageWatcher.getMemoryUsages()
val datasets =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,13 +690,36 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
}
}

/**
* Re-adds the processes the service already knows about to this activity's memory watcher.
*
* A configuration change replaces the activity and its [MemoryUsageWatcher] but not the service
* or the processes it is driving, and both pids are reported on one-shot callbacks that a
* replacement listener has already missed -- the tooling server's on the start it did not
* request, the daemon's on the build that spawned it. Without this the chart came back after a
* rotation plotting the IDE alone, which is the smallest of the three.
*/
private fun readoptWatchedProcesses(service: GradleBuildService) {
val tooling = service.toolingServerPid
val daemon = service.gradleDaemonPid
if (tooling == null && daemon == null) {
return
}

logger.info("Re-adopting watched processes: tooling server {}, Gradle daemon {}", tooling, daemon)
tooling?.let { memoryUsageWatcher.watchProcess(it, PROC_GRADLE_TOOLING) }
daemon?.let { memoryUsageWatcher.watchProcess(it, PROC_GRADLE_DAEMON) }
resetMemUsageChart()
}

protected fun onGradleBuildServiceConnected(service: GradleBuildService) {
log.info("Connected to Gradle build service")

buildServiceConnection.onConnected = null
editorViewModel.isBoundToBuildSerice = true
Lookup.getDefault().update(BuildService.KEY_BUILD_SERVICE, service)
service.setEventListener(mBuildEventListener)
readoptWatchedProcesses(service)

if (service.isToolingServerStarted()) {
if (service.isBuildInProgress) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ class EditorBuildEventListener : GradleBuildService.EventListener {
this.enabled = false
}

override fun onGradleDaemonStarted(pid: Int) {
checkActivity("onGradleDaemonStarted") ?: return
activity.watchGradleDaemon(pid)
}

override fun onGradleDaemonExited(pid: Int) {
checkActivity("onGradleDaemonExited") ?: return
activity.unwatchGradleDaemon(pid)
}

override fun prepareBuild(buildInfo: BuildInfo) {
checkActivity("prepareBuild") ?: return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ class GradleBuildService :
*/
private var toolingApiClient: ForwardingToolingApiClient? = null
private var toolingServerRunner: ToolingServerRunner? = null

/**
* The Gradle daemon's pid, or `null` when no daemon is known to be running.
*
* Remembered here and not merely forwarded, because the listener is an activity. A daemon is
* reported once, when a build spawns it, and then outlives that build; an activity recreated
* after that -- a rotation, a font-scale change -- gets a listener that hears about new daemons
* only, so its memory chart silently loses the largest of the three processes. It reads this
* instead. See [onGradleDaemonStarted].
*
* Volatile because the two ends are on different threads: the tooling API's client callbacks
* write it on the RPC reader thread, and [ProjectHandlerActivity] reads it on the main thread
* while binding. Without it a recreated activity can read a stale `null` and quietly leave the
* daemon off the chart -- the very failure this field exists to prevent.
*/
@Volatile
var gradleDaemonPid: Int? = null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private set

/**
* The tooling server's pid, or `null` while no started server has one.
*
* Same reason as [gradleDaemonPid]: [startToolingServer] reports the pid to whoever asked for
* the start, so an activity that finds the server already up never hears it.
*/
val toolingServerPid: Int?
get() = toolingServerRunner?.takeIf { it.isStarted }?.pid
private var outputReaderJob: Job? = null
private var notificationManager: NotificationManager? = null
private var server: IToolingApiServer? = null
Expand Down Expand Up @@ -420,6 +447,20 @@ class GradleBuildService :
)
}

override fun onGradleDaemonStarted(pid: Int) {
log.info("Gradle daemon started: pid {}", pid)
gradleDaemonPid = pid
eventListener?.onGradleDaemonStarted(pid)
}

override fun onGradleDaemonExited(pid: Int) {
log.info("Gradle daemon exited: pid {}", pid)
if (gradleDaemonPid == pid) {
gradleDaemonPid = null
}
eventListener?.onGradleDaemonExited(pid)
}

override fun onBuildSuccessful(result: BuildResult) {
updateNotification(getString(R.string.build_status_sucess), false)

Expand Down Expand Up @@ -760,6 +801,14 @@ class GradleBuildService :
runOnUiThread { listener.onBuildSuccessful(tasks) }
}

override fun onGradleDaemonStarted(pid: Int) {
runOnUiThread { listener.onGradleDaemonStarted(pid) }
}

override fun onGradleDaemonExited(pid: Int) {
runOnUiThread { listener.onGradleDaemonExited(pid) }
}

override fun onProgressEvent(event: ProgressEvent) {
runOnUiThread { listener.onProgressEvent(event) }
}
Expand Down Expand Up @@ -823,6 +872,25 @@ class GradleBuildService :
*/
fun onBuildSuccessful(tasks: List<String?>)

/**
* Called when the Gradle daemon has been identified by the tooling server.
*
* Defaulted, because a daemon is only of interest to a listener that plots it and every
* other implementer would otherwise gain two empty methods.
*
* @param pid The process id of the Gradle daemon.
* @see IToolingApiClient.onGradleDaemonStarted
*/
fun onGradleDaemonStarted(pid: Int) = Unit

/**
* Called when the Gradle daemon has exited.
*
* @param pid The process id of the daemon that exited.
* @see IToolingApiClient.onGradleDaemonExited
*/
fun onGradleDaemonExited(pid: Int) = Unit

/**
* Called when a progress event is received from the Tooling API server.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ internal class ToolingServerRunner(
private var listener: OnServerStartListener?,
private var observer: Observer?,
) {
/**
* The server process's pid, or `null` before it has started.
*
* Volatile for the same reason as [GradleBuildService.gradleDaemonPid]: [startAsync] writes it
* from a coroutine on [runnerScope], and the editor reads it on the main thread when it
* re-adopts the watched processes after being recreated.
*/
@Volatile
internal var pid: Int? = null
private var job: Job? = null
private var _isStarted = AtomicBoolean(false)
Expand Down
46 changes: 35 additions & 11 deletions app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package com.itsaky.androidide.utils
import android.app.ActivityManager
import android.os.Debug
import android.os.Debug.MemoryInfo
import androidx.annotation.VisibleForTesting
import androidx.collection.IntObjectMap
import androidx.collection.MutableIntObjectMap
import androidx.core.content.getSystemService
Expand All @@ -36,6 +37,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean

Expand Down Expand Up @@ -115,7 +117,8 @@ class MemoryUsageWatcher(
}
}

private fun readUsages() {
@VisibleForTesting
internal fun readUsages() {
val activityManager = BaseApplication.baseInstance.getSystemService<ActivityManager>()
if (activityManager == null) {
log.error("ActivityManager is null")
Expand All @@ -134,16 +137,28 @@ class MemoryUsageWatcher(
return@forEach
}

ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo)

// From https://developer.android.com/tools/dumpsys#meminfo
// "PSS is a good measure for the actual RAM weight of a process and for comparison against
// the RAM use of other processes and the total available RAM."
val usage = proc.memInfo.totalPss

// values are in kB, convert to bytes
val usageBytes = usage * 1024L
memoryUsage[pid]!!.apply {
// A dead process still has an entry here until whoever is watching it says otherwise, and
// Debug.getMemoryInfo leaves memInfo untouched for one -- so sampling it again would
// repeat the last reading forever and draw a flat line for a process that no longer
// exists. The Gradle daemon made this reachable: unlike the IDE and the tooling server it
// comes and goes, and it is the largest of the three (ADFA-5514). Plot a zero instead,
// which is both true and visibly the end of that process.
val usageBytes =
if (!isProcessAlive(pid)) {
0L
} else {
ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo)

// From https://developer.android.com/tools/dumpsys#meminfo
// "PSS is a good measure for the actual RAM weight of a process and for comparison against
// the RAM use of other processes and the total available RAM."
// values are in kB, convert to bytes
proc.memInfo.totalPss * 1024L
}
// [proc], not a second lookup: unwatchProcess runs on the main thread and can drop the
// entry between the two, and the Gradle daemon is unwatched from a build event
// (ADFA-5514), so the window is real rather than theoretical.
proc.apply {
// we insert the usage entry at the start of the array, then increment the shift amount by 1
// this makes the newly inserted usage entry the last element in the array
// and the oldest usage entry the first element in the array
Expand All @@ -160,6 +175,15 @@ class MemoryUsageWatcher(
}
}

/**
* Whether [pid] still names a live process.
*
* `/proc` rather than `ProcessHandle`, which Android only gained recently, or a signal probe,
* which needs a permission this does not have.
*/
@VisibleForTesting
internal var isProcessAlive: (Int) -> Boolean = { pid -> File("/proc/$pid").exists() }

/**
* Watches the memory usage of the given process.
*
Expand Down
Loading
Loading