Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 35afa1ac authored by Sanath Kumar's avatar Sanath Kumar
Browse files

Implement new stop reason for maybe abandoned jobs

Jobs timed out and detected as "Abandoned" jobs will be reported as a
new stop reason.

The new stop reason is `STOP_REASON_TIMEOUT_ABANDONED`. It will be used
to indicate that a job was terminated by the Job Scheduler because it
was detected as being abandoned and it used up its maximum execution
time.

Abandoned jobs are those that do not call `jobFinished()` or return
`false` on `onStartJob()` to finish the active job before the strong
reference to `JobParameters` is lost. These can potentially be running
nothing while the wakelock is being held, which can have a negative
affect on battery life.

bug: 372529068
Test: atest CtsJobSchedulerTestCases
Test: atest FrameworksMockingServicesTests
Flag: android.app.job.handle_abandoned_jobs

Change-Id: I5a538b7d454ae33e371e43e3afd5e85c87b4d26e
parent 77842fdf
Loading
Loading
Loading
Loading
+44 −5
Original line number Diff line number Diff line
@@ -618,6 +618,26 @@ public final class JobServiceContext implements ServiceConnection {
        return mRunningJob;
    }

    @VisibleForTesting
    void setRunningJobLockedForTest(JobStatus job) {
        mRunningJob = job;
    }

    @VisibleForTesting
    void setJobParamsLockedForTest(JobParameters params) {
        mParams = params;
    }

    @VisibleForTesting
    void setRunningCallbackLockedForTest(JobCallback callback) {
        mRunningCallback = callback;
    }

    @VisibleForTesting
    void setPendingStopReasonLockedForTest(int stopReason) {
        mPendingStopReason = stopReason;
    }

    @JobConcurrencyManager.WorkType
    int getRunningJobWorkType() {
        return mRunningJobWorkType;
@@ -786,6 +806,7 @@ public final class JobServiceContext implements ServiceConnection {
                executing = getRunningJobLocked();
            }
            if (executing != null && jobId == executing.getJobId()) {
                executing.setAbandoned(true);
                final StringBuilder stateSuffix = new StringBuilder();
                stateSuffix.append(TRACE_ABANDONED_JOB);
                stateSuffix.append(executing.getBatteryName());
@@ -1364,8 +1385,9 @@ public final class JobServiceContext implements ServiceConnection {
    }

    /** Process MSG_TIMEOUT here. */
    @VisibleForTesting
    @GuardedBy("mLock")
    private void handleOpTimeoutLocked() {
    void handleOpTimeoutLocked() {
        switch (mVerb) {
            case VERB_BINDING:
                // The system may have been too busy. Don't drop the job or trigger an ANR.
@@ -1427,9 +1449,25 @@ public final class JobServiceContext implements ServiceConnection {
                    // Not an error - client ran out of time.
                    Slog.i(TAG, "Client timed out while executing (no jobFinished received)."
                            + " Sending onStop: " + getRunningJobNameLocked());
                    mParams.setStopReason(JobParameters.STOP_REASON_TIMEOUT,
                            JobParameters.INTERNAL_STOP_REASON_TIMEOUT, "client timed out");
                    sendStopMessageLocked("timeout while executing");

                    final JobStatus executing = getRunningJobLocked();
                    int stopReason = JobParameters.STOP_REASON_TIMEOUT;
                    int internalStopReason = JobParameters.INTERNAL_STOP_REASON_TIMEOUT;
                    final StringBuilder stopMessage = new StringBuilder("timeout while executing");
                    final StringBuilder debugStopReason = new StringBuilder("client timed out");

                    if (android.app.job.Flags.handleAbandonedJobs()
                            && executing != null && executing.isAbandoned()) {
                        final String abandonedMessage = " and maybe abandoned";
                        stopReason = JobParameters.STOP_REASON_TIMEOUT_ABANDONED;
                        internalStopReason = JobParameters.INTERNAL_STOP_REASON_TIMEOUT_ABANDONED;
                        stopMessage.append(abandonedMessage);
                        debugStopReason.append(abandonedMessage);
                    }

                    mParams.setStopReason(stopReason,
                                    internalStopReason, debugStopReason.toString());
                    sendStopMessageLocked(stopMessage.toString());
                } else if (nowElapsed >= earliestStopTimeElapsed) {
                    // We've given the app the minimum execution time. See if we should stop it or
                    // let it continue running
@@ -1479,8 +1517,9 @@ public final class JobServiceContext implements ServiceConnection {
     * Already running, need to stop. Will switch {@link #mVerb} from VERB_EXECUTING ->
     * VERB_STOPPING.
     */
    @VisibleForTesting
    @GuardedBy("mLock")
    private void sendStopMessageLocked(@Nullable String reason) {
    void sendStopMessageLocked(@Nullable String reason) {
        removeOpTimeOutLocked();
        if (mVerb != VERB_EXECUTING) {
            Slog.e(TAG, "Sending onStopJob for a job that isn't started. " + mRunningJob);
+19 −0
Original line number Diff line number Diff line
@@ -575,6 +575,13 @@ public final class JobStatus {
    /** The system trace tag for this job. */
    private String mSystemTraceTag;

    /**
     * Job maybe abandoned by not calling
     * {@link android.app.job.JobService#jobFinished(JobParameters, boolean)} while
     * the strong reference to {@link android.app.job.JobParameters} is lost
     */
    private boolean mIsAbandoned;

    /**
     * Core constructor for JobStatus instances.  All other ctors funnel down to this one.
     *
@@ -725,6 +732,8 @@ public final class JobStatus {
        updateNetworkBytesLocked();

        updateMediaBackupExemptionStatus();

        mIsAbandoned = false;
    }

    /** Copy constructor: used specifically when cloning JobStatus objects for persistence,
@@ -1061,6 +1070,16 @@ public final class JobStatus {
        return job.getTraceTag();
    }

    /** Returns if the job maybe abandoned */
    public boolean isAbandoned() {
        return mIsAbandoned;
    }

    /** Set the job maybe abandoned state*/
    public void setAbandoned(boolean abandoned) {
        mIsAbandoned = abandoned;
    }

    /** Returns a trace tag using debug information provided by job scheduler service. */
    @NonNull
    public String computeSystemTraceTag() {
+270 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.server.job;

import static android.app.job.Flags.FLAG_HANDLE_ABANDONED_JOBS;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;

import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

import android.app.AppGlobals;
import android.app.job.JobParameters;
import android.content.Context;
import android.os.Looper;
import android.os.PowerManager;
import android.os.SystemClock;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;

import com.android.internal.app.IBatteryStats;
import com.android.server.job.JobServiceContext.JobCallback;
import com.android.server.job.controllers.JobStatus;

import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoSession;
import org.mockito.quality.Strictness;

import java.time.Clock;
import java.time.Duration;
import java.time.ZoneOffset;

public class JobServiceContextTest {
    private static final String TAG = JobServiceContextTest.class.getSimpleName();
    @ClassRule
    public static final SetFlagsRule.ClassRule mSetFlagsClassRule = new SetFlagsRule.ClassRule();
    @Rule
    public final SetFlagsRule mSetFlagsRule = mSetFlagsClassRule.createSetFlagsRule();
    @Mock
    private JobSchedulerService mMockJobSchedulerService;
    @Mock
    private JobConcurrencyManager mMockConcurrencyManager;
    @Mock
    private JobNotificationCoordinator mMockNotificationCoordinator;
    @Mock
    private IBatteryStats.Stub mMockBatteryStats;
    @Mock
    private JobPackageTracker mMockJobPackageTracker;
    @Mock
    private Looper mMockLooper;
    @Mock
    private Context mMockContext;
    @Mock
    private JobStatus mMockJobStatus;
    @Mock
    private JobParameters mMockJobParameters;
    @Mock
    private JobCallback mMockJobCallback;
    private MockitoSession mMockingSession;
    private JobServiceContext mJobServiceContext;
    private Object mLock;

    @Before
    public void setUp() throws Exception {
        mMockingSession =
                mockitoSession()
                        .initMocks(this)
                        .mockStatic(AppGlobals.class)
                        .strictness(Strictness.LENIENT)
                        .startMocking();
        JobSchedulerService.sElapsedRealtimeClock =
                Clock.fixed(SystemClock.elapsedRealtimeClock().instant(), ZoneOffset.UTC);
        doReturn(mock(PowerManager.class)).when(mMockContext).getSystemService(PowerManager.class);
        doReturn(mMockContext).when(mMockJobSchedulerService).getContext();
        mLock = new Object();
        doReturn(mLock).when(mMockJobSchedulerService).getLock();
        mJobServiceContext =
                new JobServiceContext(
                        mMockJobSchedulerService,
                        mMockConcurrencyManager,
                        mMockNotificationCoordinator,
                        mMockBatteryStats,
                        mMockJobPackageTracker,
                        mMockLooper);
        spyOn(mJobServiceContext);
        mJobServiceContext.setJobParamsLockedForTest(mMockJobParameters);
    }

    @After
    public void tearDown() throws Exception {
        if (mMockingSession != null) {
            mMockingSession.finishMocking();
        }
    }

    private Clock getAdvancedClock(Clock clock, long incrementMs) {
        return Clock.offset(clock, Duration.ofMillis(incrementMs));
    }

    private void advanceElapsedClock(long incrementMs) {
        JobSchedulerService.sElapsedRealtimeClock =
                getAdvancedClock(JobSchedulerService.sElapsedRealtimeClock, incrementMs);
    }

    /**
     * Test that Abandoned jobs that are timed out are stopped with the correct stop reason
     */
    @Test
    @EnableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_TimeoutAbandonedJob() {
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;
        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
        doNothing().when(mJobServiceContext).sendStopMessageLocked(captor.capture());

        advanceElapsedClock(30 * MINUTE_IN_MILLIS); // 30 minutes
        mJobServiceContext.setPendingStopReasonLockedForTest(JobParameters.STOP_REASON_UNDEFINED);

        mJobServiceContext.setRunningJobLockedForTest(mMockJobStatus);
        doReturn(true).when(mMockJobStatus).isAbandoned();
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;

        mJobServiceContext.handleOpTimeoutLocked();

        String stopMessage = captor.getValue();
        assertEquals("timeout while executing and maybe abandoned", stopMessage);
        verify(mMockJobParameters)
                .setStopReason(
                        JobParameters.STOP_REASON_TIMEOUT_ABANDONED,
                        JobParameters.INTERNAL_STOP_REASON_TIMEOUT_ABANDONED,
                        "client timed out and maybe abandoned");
    }

    /**
     * Test that non-abandoned jobs that are timed out are stopped with the correct stop reason
     */
    @Test
    @EnableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_TimeoutNoAbandonedJob() {
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;
        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
        doNothing().when(mJobServiceContext).sendStopMessageLocked(captor.capture());

        advanceElapsedClock(30 * MINUTE_IN_MILLIS); // 30 minutes
        mJobServiceContext.setPendingStopReasonLockedForTest(JobParameters.STOP_REASON_UNDEFINED);

        mJobServiceContext.setRunningJobLockedForTest(mMockJobStatus);
        doReturn(false).when(mMockJobStatus).isAbandoned();
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;

        mJobServiceContext.handleOpTimeoutLocked();

        String stopMessage = captor.getValue();
        assertEquals("timeout while executing", stopMessage);
        verify(mMockJobParameters)
                .setStopReason(
                        JobParameters.STOP_REASON_TIMEOUT,
                        JobParameters.INTERNAL_STOP_REASON_TIMEOUT,
                        "client timed out");
    }

    /**
     * Test that abandoned jobs that are timed out while the flag is disabled
     * are stopped with the correct stop reason
     */
    @Test
    @DisableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_TimeoutAbandonedJob_flagHandleAbandonedJobsDisabled() {
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;
        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
        doNothing().when(mJobServiceContext).sendStopMessageLocked(captor.capture());

        advanceElapsedClock(30 * MINUTE_IN_MILLIS); // 30 minutes
        mJobServiceContext.setPendingStopReasonLockedForTest(JobParameters.STOP_REASON_UNDEFINED);

        mJobServiceContext.setRunningJobLockedForTest(mMockJobStatus);
        doReturn(true).when(mMockJobStatus).isAbandoned();
        mJobServiceContext.mVerb = JobServiceContext.VERB_EXECUTING;

        mJobServiceContext.handleOpTimeoutLocked();

        String stopMessage = captor.getValue();
        assertEquals("timeout while executing", stopMessage);
        verify(mMockJobParameters)
                .setStopReason(
                        JobParameters.STOP_REASON_TIMEOUT,
                        JobParameters.INTERNAL_STOP_REASON_TIMEOUT,
                        "client timed out");
    }

    /**
     * Test that the JobStatus is marked as abandoned when the JobServiceContext
     * receives a MSG_HANDLE_ABANDONED_JOB message
     */
    @Test
    @EnableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_HandleAbandonedJob() {
        final int jobId = 123;
        mJobServiceContext.setRunningJobLockedForTest(mMockJobStatus);
        mJobServiceContext.setRunningCallbackLockedForTest(mMockJobCallback);
        doReturn(jobId).when(mMockJobStatus).getJobId();

        mJobServiceContext.doHandleAbandonedJob(mMockJobCallback, jobId);

        verify(mMockJobStatus).setAbandoned(true);
    }

    /**
     * Test that the JobStatus is not marked as abandoned when the
     * JobServiceContext receives a MSG_HANDLE_ABANDONED_JOB message and the
     * JobServiceContext is not running a job
     */
    @Test
    @EnableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_HandleAbandonedJob_notRunningJob() {
        final int jobId = 123;
        mJobServiceContext.setRunningJobLockedForTest(null);
        mJobServiceContext.setRunningCallbackLockedForTest(mMockJobCallback);

        mJobServiceContext.doHandleAbandonedJob(mMockJobCallback, jobId);

        verify(mMockJobStatus, never()).setAbandoned(true);
    }

    /**
     * Test that the JobStatus is not marked as abandoned when the
     * JobServiceContext receives a MSG_HANDLE_ABANDONED_JOB message and the
     * JobServiceContext is running a job with a different jobId
     */
    @Test
    @EnableFlags(FLAG_HANDLE_ABANDONED_JOBS)
    public void testJobServiceContext_HandleAbandonedJob_differentJobId() {
        final int jobId = 123;
        final int differentJobId = 456;
        mJobServiceContext.setRunningJobLockedForTest(mMockJobStatus);
        mJobServiceContext.setRunningCallbackLockedForTest(mMockJobCallback);
        doReturn(differentJobId).when(mMockJobStatus).getJobId();

        mJobServiceContext.doHandleAbandonedJob(mMockJobCallback, jobId);

        verify(mMockJobStatus, never()).setAbandoned(true);
    }

}