Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,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